fix: simplify host status to online/offline and keep it live without a refresh

This commit is contained in:
LukeGus committed 2026-10-04 14:18:50 -05:00
1 parent 2646b649d7
commit 0a436ab3d8
48 files changed
+549 -789

No files matched your search

+18 -27
View File
@@ -61,6 +61,7 @@ function isTransientStatusError(error: unknown): boolean {
);
}
/** Throws when the statuses could not be read, so callers keep what they had. */
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
@@ -71,31 +72,24 @@ export async function getAllServerStatuses(): Promise<
let remoteHostsBySyncId: Map<string, number> | null = null;
if (isElectron()) {
try {
const response = await sshHostApi.get<SSHHost[]>("/db/host");
const defaultOrigin = await resolveConnectionOrigin({
connectionOrigin: null,
});
const hosts = response.data || [];
localHostIds = hosts
// A host-list failure throws here: sending no ids would stop every check.
const response = await sshHostApi.get<SSHHost[]>("/db/host");
const defaultOrigin = await resolveConnectionOrigin({
connectionOrigin: null,
});
const hosts = response.data || [];
localHostIds = hosts
.filter((host) => (host.connectionOrigin ?? defaultOrigin) === "local")
.map((host) => host.id);
remoteHostsBySyncId = new Map(
hosts
.filter(
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
(host) =>
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
!!host.syncId,
)
.map((host) => host.id);
remoteHostsBySyncId = new Map(
hosts
.filter(
(host) =>
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
!!host.syncId,
)
.map((host) => [host.syncId as string, host.id]),
);
} catch {
// A host-list failure must not start local probes for hosts whose
// configured origin may be remote.
localHostIds = [];
}
.map((host) => [host.syncId as string, host.id]),
);
}
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
@@ -128,10 +122,7 @@ export async function getAllServerStatuses(): Promise<
}
}
if (lastError) {
handleApiError(lastError, "fetch server statuses");
return {};
}
if (lastError) handleApiError(lastError, "fetch server statuses");
if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
try {
+5 -31
View File
@@ -1,7 +1,7 @@
import { AxiosError } from "axios";
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
import { handleApiError, sshHostApi } from "@/main-axios";
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import {
getCachedSSHHosts,
invalidateHostsAndStatusCaches,
@@ -10,42 +10,16 @@ import {
// SSH HOST MANAGEMENT
// ============================================================================
export type GetSSHHostsOptions = {
/** When false, skip the status service call (host config only). Default true. */
includeStatus?: boolean;
};
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host");
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
}
export async function getSSHHosts(
options: GetSSHHostsOptions = {},
): Promise<SSHHostWithStatus[]> {
const includeStatus = options.includeStatus !== false;
/** Host config only. Live status comes from ServerStatusContext. */
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
try {
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
if (!includeStatus) {
return hosts.map((host) => ({
...host,
status: "unknown",
}));
}
let statuses: Record<number, ServerStatus> = {};
try {
statuses = (await getAllServerStatuses()) || {};
} catch {
// Status fetch failure should not prevent host list from loading
}
return hosts.map((host) => ({
...host,
status: statuses[host.id]?.status || "unknown",
}));
return hosts.map((host) => ({ ...host, status: "unknown" }));
} catch (error) {
throw handleApiError(error, "fetch SSH hosts");
}
+19 -15
View File
@@ -57,7 +57,11 @@ import {
useStatusColorScheme,
getStatusClasses,
} from "@/hooks/use-status-color-scheme";
import { useServerStatusMeta } from "@/lib/ServerStatusContext";
import {
useServerStatus,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
import { withLiveHostStatus } from "@/sidebar/live-host-status";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
@@ -429,7 +433,7 @@ export function HostStatusCard({
</span>
</div>
<span className="text-xs text-muted-foreground">
{online}/{hosts.length} {t("hosts.status.available")}
{online}/{hosts.length} {t("hosts.status.online")}
</span>
</div>
<div className="flex flex-col overflow-auto flex-1">
@@ -439,12 +443,7 @@ export function HostStatusCard({
</div>
)}
{hosts.map((host, i) => {
const availability =
host.status && host.status !== "unknown"
? host.status
: host.online
? "online"
: "offline";
const availability = host.status ?? "offline";
return (
<div
key={i}
@@ -492,13 +491,11 @@ export function HostStatusCard({
<span
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
>
{statusLoading
{statusLoading || availability === "unknown"
? t("dashboardTab.checking")
: availability === "online"
? t("hosts.status.available")
: availability === "reachable"
? t("hosts.status.reachable")
: t("hosts.status.offline")}
? t("hosts.status.online")
: t("hosts.status.offline")}
</span>
</div>
</div>
@@ -582,7 +579,7 @@ function RecentActivityCard({
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot", statusLoading)}`}
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.status ?? false, statusScheme, "dot", statusLoading)}`}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs font-semibold truncate">
@@ -1102,7 +1099,14 @@ export function DashboardTab({
}
}, [mainWidthPct]);
const [hosts, setHosts] = useState<Host[]>([]);
const [configHosts, setHosts] = useState<Host[]>([]);
const { statuses, getStatus } = useServerStatus();
const hosts = useMemo(
() => withLiveHostStatus(configHosts, getStatus),
// statuses changes identity whenever a host's status changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[configHosts, statuses, getStatus],
);
const [isAdmin, setIsAdmin] = useState(false);
const [uptimeFormatted, setUptimeFormatted] = useState("");
const [versionText, setVersionText] = useState("");
+2 -8
View File
@@ -48,14 +48,13 @@ export function useStatusColorScheme(): StatusColorScheme {
/** Returns Tailwind class names for a status dot/stripe. */
export function getStatusClasses(
status: boolean | "online" | "reachable" | "offline" | "degraded",
status: boolean | "online" | "offline" | "unknown",
scheme: StatusColorScheme,
variant: "dot" | "stripe" | "badge",
loading = false,
): string {
const online = status === true || status === "online";
const reachable = status === "reachable";
if (loading) {
if (loading || status === "unknown") {
if (scheme === "status") {
if (variant === "dot") return "bg-yellow-400 animate-pulse";
if (variant === "stripe") return "bg-yellow-400/40 animate-pulse";
@@ -65,11 +64,6 @@ export function getStatusClasses(
if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
return "border-border/50 text-muted-foreground/50 bg-muted/20 animate-pulse";
}
if (reachable) {
if (variant === "dot") return "bg-amber-400";
if (variant === "stripe") return "bg-amber-400/50";
return "border-amber-400/40 text-amber-400 bg-amber-400/10";
}
if (scheme === "status") {
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
if (variant === "stripe")
+85 -189
View File
@@ -9,7 +9,8 @@ import React, {
useSyncExternalStore,
useState,
} from "react";
import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
import { getAllServerStatuses } from "@/main-axios";
import { invalidateServerStatusCache } from "./hosts-request-cache";
import {
ServerStatusStore,
type ServerStatusEntry,
@@ -19,17 +20,26 @@ import { runAdaptivePolling } from "./adaptive-polling";
interface ServerStatusContextType {
statuses: Map<number, ServerStatusEntry>;
isLoading: boolean;
initialLoadComplete: boolean;
refreshStatuses: () => Promise<void>;
/** "unknown" until the host has been checked. */
getStatus: (hostId: number) => StatusValue;
}
/** Stable for the provider lifetime — fine-grained hooks only need this. */
/** Stable for the provider lifetime. Fine-grained hooks only need this. */
const StatusStoreContext = createContext<ServerStatusStore | null>(null);
const ServerStatusContext = createContext<ServerStatusContextType | null>(null);
const POLL_INTERVAL = 30000;
/** The mounted provider's store, for code outside React (plugin host lists). */
let activeStore: ServerStatusStore | null = null;
/** A host's live status outside React. "unknown" with no provider mounted. */
export function getLiveHostStatus(hostId: number): StatusValue {
return activeStore?.getStatus(hostId) ?? "unknown";
}
/** The backend keeps statuses in memory, so reading them often is cheap. */
const POLL_INTERVAL = 15_000;
export function ServerStatusProvider({
children,
@@ -47,121 +57,48 @@ export function ServerStatusProvider({
// Bumps only full-context consumers (dashboard, folder counts, etc.).
const [version, setVersion] = useState(0);
const mountedRef = useRef(true);
const refreshInFlightRef = useRef<Promise<boolean | void> | null>(null);
const refreshInFlightRef = useRef<Promise<void> | null>(null);
useEffect(() => store.subscribeAll(() => setVersion((v) => v + 1)), [store]);
useEffect(() => {
return store.subscribeAll(() => {
setVersion((v) => v + 1);
});
activeStore = store;
return () => {
if (activeStore === store) activeStore = null;
};
}, [store]);
useEffect(() => {
return store.subscribeMeta(() => {
setVersion((v) => v + 1);
});
}, [store]);
const fetchEnabledHosts = useCallback(async () => {
if (!isAuthenticated) {
store.setEnabledHostIds(new Set());
return new Set<number>();
}
try {
const hosts = await getSSHHosts({ includeStatus: false });
const enabled = new Set<number>();
hosts.forEach((host) => {
if (host.statusCheckEnabled !== false) enabled.add(host.id);
});
store.setEnabledHostIds(enabled);
return enabled;
} catch {
return store.getEnabledHostIds();
}
}, [isAuthenticated, store]);
const refreshStatusesImpl = useCallback(
async (rethrow = false) => {
if (!mountedRef.current || !isAuthenticated) return;
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return;
}
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
const showLoading = !store.getInitialLoadComplete();
if (showLoading) store.setLoading(true);
const run = (async () => {
try {
const data = await getAllServerStatuses();
if (!mountedRef.current) return;
const newStatuses = new Map<number, ServerStatusEntry>();
const now = new Date().toISOString();
if (data && typeof data === "object") {
Object.entries(data).forEach(([idStr, statusData]) => {
const id = parseInt(idStr, 10);
if (!isNaN(id)) {
const status =
statusData?.status === "online" ||
statusData?.status === "reachable"
? statusData.status
: "offline";
newStatuses.set(id, {
status,
lastChecked: statusData?.lastChecked || now,
...(statusData?.reason === "host_key_changed"
? { reason: statusData.reason }
: {}),
});
}
});
}
const previousStatuses = store.getStatuses();
const changed =
previousStatuses.size !== newStatuses.size ||
[...newStatuses].some(
([id, entry]) =>
previousStatuses.get(id)?.status !== entry.status ||
previousStatuses.get(id)?.reason !== entry.reason,
);
store.applyStatuses(newStatuses);
return changed;
} catch (error) {
if (mountedRef.current) {
store.markDegraded(store.getEnabledHostIds());
}
if (rethrow) throw error;
return false;
} finally {
if (mountedRef.current) {
if (showLoading) store.setLoading(false);
store.setInitialLoadComplete(true);
}
}
})();
refreshInFlightRef.current = run.finally(() => {
refreshInFlightRef.current = null;
});
return refreshInFlightRef.current;
},
[isAuthenticated, store],
);
const refreshStatuses = useCallback(async () => {
await refreshStatusesImpl();
}, [refreshStatusesImpl]);
if (!mountedRef.current || !isAuthenticated) return;
if (refreshInFlightRef.current) return refreshInFlightRef.current;
const run = (async () => {
try {
const data = await getAllServerStatuses();
if (!mountedRef.current || !data || typeof data !== "object") return;
const next = new Map<number, ServerStatusEntry>();
const now = new Date().toISOString();
for (const [idStr, entry] of Object.entries(data)) {
const id = parseInt(idStr, 10);
if (isNaN(id)) continue;
next.set(id, {
status: entry?.status === "online" ? "online" : "offline",
lastChecked: entry?.lastChecked || now,
});
}
store.applyStatuses(next);
} catch {
// Keep the last known statuses rather than flipping every host.
} finally {
if (mountedRef.current) store.setInitialLoadComplete(true);
}
})();
refreshInFlightRef.current = run.finally(() => {
refreshInFlightRef.current = null;
});
return refreshInFlightRef.current;
}, [isAuthenticated, store]);
const getStatus = useCallback(
(hostId: number): StatusValue => store.getStatus(hostId),
@@ -170,35 +107,31 @@ export function ServerStatusProvider({
useEffect(() => {
mountedRef.current = true;
if (!isAuthenticated) {
store.clear();
return () => {
mountedRef.current = false;
};
}
let stopPolling: (() => void) | null = null;
const init = async () => {
await fetchEnabledHosts();
if (!mountedRef.current) return;
stopPolling = runAdaptivePolling(
() => refreshStatusesImpl(true),
{
minIntervalMs: POLL_INTERVAL,
maxIntervalMs: 120_000,
stablePollsPerStep: 3,
},
{ enabled: () => isAuthenticated },
);
};
void init();
const stopPolling = runAdaptivePolling(refreshStatuses, {
minIntervalMs: POLL_INTERVAL,
maxIntervalMs: POLL_INTERVAL,
});
const onFocus = () => void refreshStatuses();
window.addEventListener("focus", onFocus);
return () => {
mountedRef.current = false;
stopPolling?.();
stopPolling();
window.removeEventListener("focus", onFocus);
};
}, [fetchEnabledHosts, isAuthenticated, refreshStatusesImpl]);
}, [isAuthenticated, refreshStatuses, store]);
useEffect(() => {
const handleHostsChanged = async () => {
await fetchEnabledHosts();
await refreshStatuses();
const handleHostsChanged = () => {
invalidateServerStatusCache();
void refreshStatuses();
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
@@ -208,17 +141,16 @@ export function ServerStatusProvider({
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
window.removeEventListener("hosts:refresh", handleHostsChanged);
};
}, [fetchEnabledHosts, refreshStatuses]);
}, [refreshStatuses]);
const contextValue = useMemo(
() => ({
statuses: store.getStatuses(),
isLoading: store.getIsLoading(),
initialLoadComplete: store.getInitialLoadComplete(),
refreshStatuses,
getStatus,
}),
// version refreshes statuses/isLoading/initialLoadComplete snapshots
// version refreshes the statuses/initialLoadComplete snapshots
// eslint-disable-next-line react-hooks/exhaustive-deps
[version, refreshStatuses, getStatus],
);
@@ -252,85 +184,49 @@ export function useServerStatus() {
/**
* Subscribe to a single host's status. Only re-renders when that host's
* status value changes (or its enabled flag flips). Does not re-render when
* other hosts update.
* status changes. Null when status checks are off for the host.
*/
export function useHostStatus(
hostId: number,
statusCheckEnabled: boolean = true,
): StatusValue | null {
const store = useStatusStore();
const snapshot = useSyncExternalStore(
const status = useSyncExternalStore(
(onChange) => store.subscribeHost(hostId, onChange),
() => store.getHostSnapshot(hostId),
() => store.getHostSnapshot(hostId),
() => store.getStatus(hostId),
() => store.getStatus(hostId),
);
if (!statusCheckEnabled) {
return null;
}
return snapshot.split(":", 1)[0] as StatusValue;
}
export function useHostStatusReason(
hostId: number,
statusCheckEnabled: boolean = true,
): ServerStatusEntry["reason"] | null {
const store = useStatusStore();
const snapshot = useSyncExternalStore(
(onChange) => store.subscribeHost(hostId, onChange),
() => store.getHostSnapshot(hostId),
() => store.getHostSnapshot(hostId),
);
if (!statusCheckEnabled) return null;
return snapshot.endsWith(":host_key_changed")
? "host_key_changed"
: undefined;
return statusCheckEnabled ? status : null;
}
/** Meta flags without depending on the full status map. */
export function useServerStatusMeta(): {
initialLoadComplete: boolean;
isLoading: boolean;
} {
export function useServerStatusMeta(): { initialLoadComplete: boolean } {
const store = useStatusStore();
useSyncExternalStore(
(onChange) => store.subscribeMeta(onChange),
() => store.getMetaSnapshot(),
() => store.getMetaSnapshot(),
);
return {
initialLoadComplete: store.getInitialLoadComplete(),
isLoading: store.getIsLoading(),
};
return { initialLoadComplete: store.getInitialLoadComplete() };
}
const noStatus = () => "";
const noStatus = (): StatusValue => "unknown";
/**
* One host's status for plugin views, which can render outside the app shell
* (a standalone window). Null there, and while the host has not been checked.
*/
export function useOptionalHostStatusEntry(hostId: number | undefined): {
status: StatusValue;
reason?: "host_key_changed";
} | null {
export function useOptionalHostStatusEntry(
hostId: number | undefined,
): { status: "online" | "offline" } | null {
const store = useContext(StatusStoreContext);
const snapshot = useSyncExternalStore(
const status = useSyncExternalStore(
(onChange) =>
store && hostId !== undefined
? store.subscribeHost(hostId, onChange)
: () => {},
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""),
() => (store && hostId !== undefined ? store.getStatus(hostId) : "unknown"),
noStatus,
);
if (!snapshot) return null;
const [status] = snapshot.split(":", 1);
if (!status) return null;
return snapshot.endsWith(":host_key_changed")
? { status: status as StatusValue, reason: "host_key_changed" }
: { status: status as StatusValue };
return status === "unknown" ? null : { status };
}
+12 -87
View File
@@ -1,9 +1,8 @@
export type StatusValue = "online" | "reachable" | "offline" | "degraded";
export type StatusValue = "online" | "offline" | "unknown";
export interface ServerStatusEntry {
status: StatusValue;
status: "online" | "offline";
lastChecked: string;
reason?: "host_key_changed";
}
type Listener = () => void;
@@ -14,44 +13,26 @@ type Listener = () => void;
*/
export class ServerStatusStore {
private statuses = new Map<number, ServerStatusEntry>();
private enabledHostIds = new Set<number>();
private initialLoadComplete = false;
private isLoading = false;
private readonly hostListeners = new Map<number, Set<Listener>>();
private readonly allListeners = new Set<Listener>();
private readonly metaListeners = new Set<Listener>();
/** "unknown" until the host has been checked. */
getStatus(hostId: number): StatusValue {
if (!this.enabledHostIds.has(hostId)) {
return "offline";
}
return this.statuses.get(hostId)?.status || "degraded";
return this.statuses.get(hostId)?.status ?? "unknown";
}
getStatuses(): Map<number, ServerStatusEntry> {
return this.statuses;
}
getEnabledHostIds(): Set<number> {
return this.enabledHostIds;
}
getInitialLoadComplete(): boolean {
return this.initialLoadComplete;
}
getIsLoading(): boolean {
return this.isLoading;
}
/** Snapshot string for a host — used as useSyncExternalStore getSnapshot. */
getHostSnapshot(hostId: number): string {
const status = this.getStatus(hostId);
return `${status}:${this.statuses.get(hostId)?.reason ?? ""}`;
}
getMetaSnapshot(): string {
return `${this.initialLoadComplete ? 1 : 0}:${this.isLoading ? 1 : 0}:${this.enabledHostIds.size}`;
return this.initialLoadComplete ? "1" : "0";
}
subscribeHost(hostId: number, listener: Listener): () => void {
@@ -83,28 +64,6 @@ export class ServerStatusStore {
};
}
setEnabledHostIds(next: Set<number>): void {
if (setsEqual(this.enabledHostIds, next)) return;
const prev = this.enabledHostIds;
this.enabledHostIds = next;
this.emitMeta();
// Hosts that entered/left the enabled set need a status re-read.
for (const id of next) {
if (!prev.has(id)) this.emitHost(id);
}
for (const id of prev) {
if (!next.has(id)) this.emitHost(id);
}
this.emitAll();
}
setLoading(loading: boolean): void {
if (this.isLoading === loading) return;
this.isLoading = loading;
this.emitMeta();
this.emitAll();
}
setInitialLoadComplete(complete: boolean): void {
if (this.initialLoadComplete === complete) return;
this.initialLoadComplete = complete;
@@ -120,50 +79,24 @@ export class ServerStatusStore {
const changedIds: number[] = [];
for (const [id, entry] of next) {
const prev = this.statuses.get(id);
if (
!prev ||
prev.status !== entry.status ||
prev.reason !== entry.reason
) {
changedIds.push(id);
}
if (this.statuses.get(id)?.status !== entry.status) changedIds.push(id);
}
for (const id of this.statuses.keys()) {
if (!next.has(id)) {
changedIds.push(id);
}
}
if (changedIds.length === 0) {
// Still refresh lastChecked silently without notifying.
this.statuses = next;
return;
if (!next.has(id)) changedIds.push(id);
}
this.statuses = next;
if (changedIds.length === 0) return;
for (const id of changedIds) {
this.emitHost(id);
}
this.emitAll();
}
markDegraded(enabledIds: Iterable<number>): void {
const next = new Map(this.statuses);
let changed = false;
const now = new Date().toISOString();
for (const id of enabledIds) {
const existing = next.get(id);
if (existing?.status === "degraded") continue;
changed = true;
next.set(id, {
status: "degraded",
lastChecked: existing?.lastChecked || now,
});
}
if (changed) {
this.applyStatuses(next);
}
/** Forgets everything, on sign out. */
clear(): void {
this.applyStatuses(new Map());
this.setInitialLoadComplete(false);
}
private emitHost(hostId: number): void {
@@ -180,11 +113,3 @@ export class ServerStatusStore {
for (const listener of this.metaListeners) listener();
}
}
function setsEqual(a: Set<number>, b: Set<number>): boolean {
if (a.size !== b.size) return false;
for (const id of a) {
if (!b.has(id)) return false;
}
return true;
}
+3 -4
View File
@@ -122,7 +122,6 @@
"minimize": "Minimize",
"online": "Online",
"offline": "Offline",
"signInFailed": "Sign in failed",
"unknown": "Unknown",
"continue": "Continue",
"maintenance": "Maintenance",
@@ -358,10 +357,10 @@
"tunnel": "Tunnel",
"serverStats": "Host Metrics",
"status": {
"available": "Available",
"reachable": "Available, but login failed",
"online": "Online",
"offline": "Offline",
"monitoringDisabled": "Monitoring disabled",
"checking": "Checking",
"monitoringDisabled": "Status checks off",
"label": "Status"
},
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
+2 -3
View File
@@ -63,13 +63,12 @@ import { dbHealthMonitor } from "@/lib/db-health-monitor";
import { getDeviceId } from "@/lib/device-id";
export type ServerStatus = {
status: "online" | "reachable" | "offline";
status: "online" | "offline";
lastChecked: string;
reason?: "host_key_changed";
};
export type SSHHostWithStatus = SSHHost & {
status: "online" | "reachable" | "offline" | "unknown";
status: "online" | "offline" | "unknown";
};
export interface AuthResponse {
+12 -10
View File
@@ -46,7 +46,10 @@ import { invokeAction } from "@/shell/action-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useActionSlot } from "@/hooks/use-action-slot";
import { usePluginComponent } from "./component-registry";
import { useOptionalHostStatusEntry } from "@/lib/ServerStatusContext";
import {
getLiveHostStatus,
useOptionalHostStatusEntry,
} from "@/lib/ServerStatusContext";
/**
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
@@ -370,12 +373,7 @@ export const pluginHostBridge: PluginHostBridge = {
usePluginComponent: (id) => usePluginComponent(id),
useHostStatus: (hostId) => {
const entry = useOptionalHostStatusEntry(hostId);
if (!entry) return null;
return {
status: entry.status === "degraded" ? "unknown" : entry.status,
...(entry.reason ? { reason: entry.reason } : {}),
};
return useOptionalHostStatusEntry(hostId);
},
useHostActions: () => useHostActions() as unknown as HostActionContribution[],
@@ -429,9 +427,13 @@ export const pluginHostBridge: PluginHostBridge = {
void setCookie(name, value, 365);
},
listHosts: async (pluginId) =>
(await getSSHHosts({ includeStatus: false })).map((host) =>
toPluginHostRecord(host, pluginId ?? null),
),
(await getSSHHosts()).map((host) => {
const status = getLiveHostStatus(host.id);
return toPluginHostRecord(
{ ...host, status, online: status === "online" },
pluginId ?? null,
);
}),
notifyHostsChanged: () => {
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
},
+4 -2
View File
@@ -44,6 +44,7 @@ import {
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
interface CommandPaletteProps {
isOpen: boolean;
@@ -501,7 +502,7 @@ export function CommandPalette({
<Server
className={cn(
"size-4",
host.online
getLiveHostStatus(Number(host.id)) === "online"
? "text-accent-brand"
: "text-muted-foreground",
)}
@@ -517,7 +518,8 @@ export function CommandPalette({
{t("hosts.sharing.sharedBadge")}
</span>
)}
{host.online && (
{getLiveHostStatus(Number(host.id)) ===
"online" && (
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
)}
</div>
+1 -1
View File
@@ -117,7 +117,7 @@ export function AdminSettingsPanel({
const [passwordLoginForced, setPasswordLoginForced] = useState(false);
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("60");
const [statusInterval, setStatusInterval] = useState("30");
const [logLevel, setLogLevel] = useState("info");
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
+13 -6
View File
@@ -1,7 +1,7 @@
import { getErrorMessage } from "../lib/error-message.js";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { HostData } from "@/types/index";
import { useTranslation } from "react-i18next";
import {
@@ -53,6 +53,8 @@ import {
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
import { withLiveStatus } from "@/sidebar/live-host-status";
import { useServerStatus } from "@/lib/ServerStatusContext";
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import type {
@@ -137,9 +139,7 @@ function hostPassesFilters(
if (filters.status.length > 0) {
const ok =
(filters.status.includes("online") && host.online) ||
(filters.status.includes("offline") &&
host.status !== "reachable" &&
!host.online) ||
(filters.status.includes("offline") && host.status === "offline") ||
(filters.status.includes("pinned") && !!host.pin);
if (!ok) return false;
}
@@ -219,6 +219,13 @@ export function HostsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const { statuses, getStatus } = useServerStatus();
const liveHostTree = useMemo(
() => (hostTree ? withLiveStatus(hostTree, getStatus) : undefined),
// statuses changes identity whenever a host's status changes
// eslint-disable-next-line react-hooks/exhaustive-deps
[hostTree, statuses, getStatus],
);
const hostSwitchPlugins = usePluginHostSections().filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
);
@@ -1028,10 +1035,10 @@ export function HostsPanel({
<div className="flex flex-col flex-1 min-h-0">
<SidebarTree
children={
hostTree
liveHostTree
? groupHosts(
applyFilters(
sortHostTree(hostTree, sortKey, pinnedFirst),
sortHostTree(liveHostTree, sortKey, pinnedFirst),
filterState,
hostSwitches,
),
+36
View File
@@ -0,0 +1,36 @@
import type { Host, HostFolder } from "@/types/ui-types";
import type { StatusValue } from "@/lib/server-status-store";
import { isFolder } from "./tree/visible-rows";
/**
* Copies each host's live status onto the tree so sorting, filtering and
* grouping by status read the same value as the row's dot. A host with
* status checks off gets no status.
*/
export function withLiveStatus(
folder: HostFolder,
getStatus: (hostId: number) => StatusValue,
): HostFolder {
return {
...folder,
children: folder.children.map((child) => {
if (isFolder(child)) return withLiveStatus(child, getStatus);
const host = child as Host;
const status =
host.statusCheckEnabled === false
? undefined
: getStatus(Number(host.id));
if (host.status === status) return host;
return { ...host, status, online: status === "online" };
}),
};
}
/** The flat-list version of withLiveStatus. */
export function withLiveHostStatus(
hosts: Host[],
getStatus: (hostId: number) => StatusValue,
): Host[] {
return withLiveStatus({ name: "", children: hosts }, getStatus)
.children as Host[];
}
+9 -22
View File
@@ -15,23 +15,12 @@ import { HostItem, statusCheckEnabled } from "../HostItem/HostItem";
import { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
import { FolderActions } from "./FolderActions";
export function folderHostCount(folder: HostFolder): {
total: number;
online: number;
} {
let total = 0,
online = 0;
export function folderHostCount(folder: HostFolder): number {
let total = 0;
for (const child of folder.children) {
if (isFolder(child)) {
const c = folderHostCount(child);
total += c.total;
online += c.online;
} else {
total++;
if (child.online) online++;
}
total += isFolder(child) ? folderHostCount(child) : 1;
}
return { total, online };
return total;
}
export function FolderItem({
@@ -140,13 +129,11 @@ export function FolderItem({
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
}) {
const { t } = useTranslation();
const { getStatus, initialLoadComplete } = useServerStatus();
const { total } = folderHostCount(folder);
const online = initialLoadComplete
? collectAllHosts(folder.children).filter(
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
).length
: folderHostCount(folder).online;
const { getStatus } = useServerStatus();
const total = folderHostCount(folder);
const online = collectAllHosts(folder.children).filter(
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
).length;
const [dragOver, setDragOver] = useState(false);
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
+11 -28
View File
@@ -59,11 +59,7 @@ import {
useStatusColorScheme,
getStatusClasses,
} from "@/hooks/use-status-color-scheme";
import {
useHostStatus,
useHostStatusReason,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
import { useHostStatus } from "@/lib/ServerStatusContext";
import {
Tooltip,
TooltipContent,
@@ -101,16 +97,16 @@ export function statusCheckEnabled(host: Host): boolean {
export function buildStatusTooltip(
host: Host,
status: "online" | "reachable" | "offline",
status: "online" | "offline" | "unknown",
t: (key: string) => string = (k) => k,
): string {
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
const statusLabel =
status === "online"
? t("hosts.status.available")
: status === "reachable"
? t("hosts.status.reachable")
: t("hosts.status.offline");
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
? t("hosts.status.online")
: status === "offline"
? t("hosts.status.offline")
: t("hosts.status.checking");
const protocols: string[] = [];
if (host.enableSsh) protocols.push("SSH");
for (const protocol of enabledHostProtocols(host)) {
@@ -283,22 +279,11 @@ export function HostItem({
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const splitTargets = useSplitTargets();
const statusScheme = useStatusColorScheme();
const { initialLoadComplete } = useServerStatusMeta();
const statusCheckOn = statusCheckEnabled(host);
const statusLoading = !initialLoadComplete && statusCheckOn;
// Per-host subscription — status polls only re-render rows that flipped.
const liveStatus = useHostStatus(Number(host.id), statusCheckOn);
const statusReason = useHostStatusReason(Number(host.id), statusCheckOn);
// Per-host subscription, so a poll only re-renders rows that flipped.
const availability =
liveStatus === "online" ||
liveStatus === "reachable" ||
liveStatus === "offline"
? liveStatus
: host.status === "reachable"
? "reachable"
: host.online
? "online"
: "offline";
useHostStatus(Number(host.id), statusCheckOn) ?? "offline";
const statusLoading = availability === "unknown";
const isOnline = availability === "online";
const previousAvailability = useRef(availability);
const [statusLocking, setStatusLocking] = useState(false);
@@ -1136,9 +1121,7 @@ export function HostItem({
</span>
</TooltipTrigger>
<TooltipContent side="right">
{statusReason === "host_key_changed"
? `${t("hostKey.keyChangedWarning")}: ${t("hostKey.keyChangedDescription")}`
: buildStatusTooltip(host, availability, t)}
{buildStatusTooltip(host, availability, t)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
+1 -1
View File
@@ -502,7 +502,7 @@ export function SidebarTree({
function handleDeleteFolder(folder: HostFolder) {
const folderPath = folder.path ?? folder.name;
const { total } = folderHostCount(folder);
const total = folderHostCount(folder);
setConfirmDialog({
message: t("hosts.deleteFolderConfirm", {
name: folder.name,
+15 -2
View File
@@ -49,7 +49,7 @@ beforeEach(() => {
vi.clearAllMocks();
// The server keys statuses by sync id; its own ids mean nothing here.
remoteCoreApiMock.get.mockResolvedValue({
data: { "sync-2": { status: "reachable" }, "sync-9": { status: "x" } },
data: { "sync-2": { status: "offline" }, "sync-9": { status: "x" } },
});
});
@@ -65,7 +65,7 @@ describe("status check origin routing", () => {
await expect(getAllServerStatuses()).resolves.toEqual({
1: { status: "online" },
2: { status: "reachable" },
2: { status: "offline" },
});
expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", {
@@ -91,6 +91,19 @@ describe("status check origin routing", () => {
__silentRetry: true,
});
});
it("skips the tick when the host list cannot be read", async () => {
sshHostApiMock.get.mockImplementation(async (path: string) => {
if (path === "/db/host") throw new Error("down");
return { data: {} };
});
await expect(getAllServerStatuses()).rejects.toThrow("down");
expect(sshHostApiMock.get).not.toHaveBeenCalledWith(
"/status",
expect.anything(),
);
});
});
describe("status check routes", () => {
@@ -66,8 +66,8 @@ describe("getStatusClasses", () => {
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand");
});
it("draws reachable as a warning in both schemes", () => {
expect(getStatusClasses("reachable", "accent", "dot")).toContain("amber");
expect(getStatusClasses("reachable", "status", "dot")).toContain("amber");
it("draws an unchecked host as loading", () => {
expect(getStatusClasses("unknown", "accent", "dot")).toContain("pulse");
expect(getStatusClasses("unknown", "status", "dot")).toContain("pulse");
});
});
+11 -37
View File
@@ -4,7 +4,6 @@ import { ServerStatusStore } from "../../lib/server-status-store";
describe("ServerStatusStore", () => {
it("notifies only listeners for hosts whose status value changed", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1, 2, 3]));
const on1 = vi.fn();
const on2 = vi.fn();
@@ -43,7 +42,6 @@ describe("ServerStatusStore", () => {
it("does not notify when the status map is unchanged", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1]));
store.applyStatuses(
new Map([[1, { status: "online", lastChecked: "t1" }]]),
);
@@ -57,47 +55,23 @@ describe("ServerStatusStore", () => {
expect(store.getStatus(1)).toBe("online");
});
it("returns offline for disabled hosts", () => {
it("calls a host it has no result for unknown, never offline", () => {
const store = new ServerStatusStore();
expect(store.getStatus(5)).toBe("unknown");
});
it("forgets everything on clear", () => {
const store = new ServerStatusStore();
store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]]));
expect(store.getStatus(5)).toBe("offline");
store.setEnabledHostIds(new Set([5]));
expect(store.getStatus(5)).toBe("online");
});
it("preserves reachable as distinct from authenticated online", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([7]));
store.applyStatuses(
new Map([[7, { status: "reachable", lastChecked: "t" }]]),
);
expect(store.getStatus(7)).toBe("reachable");
});
it("notifies when a status reason changes without a status change", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([7]));
store.applyStatuses(
new Map([[7, { status: "reachable", lastChecked: "t1" }]]),
);
store.setInitialLoadComplete(true);
const listener = vi.fn();
store.subscribeHost(7, listener);
store.subscribeHost(5, listener);
store.applyStatuses(
new Map([
[
7,
{
status: "reachable",
reason: "host_key_changed",
lastChecked: "t2",
},
],
]),
);
store.clear();
expect(store.getStatus(5)).toBe("unknown");
expect(store.getInitialLoadComplete()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getHostSnapshot(7)).toBe("reachable:host_key_changed");
});
it("emits meta listeners when initial load completes", () => {
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { Host, HostFolder } from "@/types/ui-types";
import { withLiveHostStatus, withLiveStatus } from "@/sidebar/live-host-status";
const host = (id: number, extra: Partial<Host> = {}) =>
({ id: String(id), name: `h${id}`, online: false, ...extra }) as Host;
const statuses: Record<number, "online" | "offline"> = {
1: "online",
2: "offline",
};
const getStatus = (id: number) => statuses[id] ?? "unknown";
describe("withLiveStatus", () => {
it("copies live statuses into nested folders", () => {
const tree: HostFolder = {
name: "root",
children: [host(1), { name: "f", children: [host(2), host(3)] }],
};
const live = withLiveStatus(tree, getStatus);
const [first, folder] = live.children as [Host, HostFolder];
const [second, third] = folder.children as Host[];
expect(first).toMatchObject({ status: "online", online: true });
expect(second).toMatchObject({ status: "offline", online: false });
expect(third).toMatchObject({ status: "unknown", online: false });
});
it("gives a host with status checks off no status", () => {
const [live] = withLiveHostStatus(
[host(1, { statusCheckEnabled: false })],
getStatus,
);
expect(live.status).toBeUndefined();
expect(live.online).toBe(false);
});
it("keeps the same object when nothing changed", () => {
const same = host(1, { status: "online", online: true });
expect(withLiveHostStatus([same], getStatus)[0]).toBe(same);
});
});
@@ -27,12 +27,11 @@ vi.mock("react-i18next", () => ({
vi.mock("@/lib/ServerStatusContext", () => ({
useHostStatus: () => null,
useHostStatusReason: () => null,
useServerStatus: () => ({
getStatus: () => "online",
initialLoadComplete: true,
}),
useServerStatusMeta: () => ({ initialLoadComplete: true, isLoading: false }),
useServerStatusMeta: () => ({ initialLoadComplete: true }),
}));
vi.mock("@/hooks/use-status-color-scheme", () => ({
@@ -27,24 +27,24 @@ function makeHost(overrides: Partial<Host> = {}): Host {
// not the key paths the translator falls back to when a resource is missing.
const t = (key: string): string =>
({
"hosts.status.available": "Available",
"hosts.status.reachable": "Reachable, not authenticated",
"hosts.status.online": "Online",
"hosts.status.checking": "Checking",
"hosts.status.offline": "Offline",
"hosts.status.monitoringDisabled": "Monitoring disabled",
})[key] ?? key;
describe("buildStatusTooltip", () => {
it("returns the translated 'Available' label for online status", () => {
it("returns the translated 'Online' label for online status", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toContain("Available");
expect(tooltip).toContain("Online");
expect(tooltip).not.toContain("hosts.status.");
});
it("returns the translated 'Reachable, not authenticated' label for reachable status", () => {
it("returns 'Checking' before the host has been checked", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "reachable", t);
expect(tooltip).toContain("Reachable, not authenticated");
const tooltip = buildStatusTooltip(host, "unknown", t);
expect(tooltip).toContain("Checking");
expect(tooltip).not.toContain("hosts.status.");
});
@@ -78,13 +78,13 @@ describe("buildStatusTooltip", () => {
dispose();
expect(tooltip).toContain("SSH");
expect(tooltip).toContain("Demo Desktop");
expect(tooltip).toContain("Available");
expect(tooltip).toContain("Online");
});
it("returns just the status label when no protocols are enabled", () => {
const host = makeHost({ enableSsh: false });
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toBe("Available");
expect(tooltip).toBe("Online");
});
it("does not render key paths when a translator is supplied", () => {