From 0a436ab3d857137c2bd2ccafb02d3deb149da840 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 4 Oct 2026 14:18:50 -0500 Subject: [PATCH] fix: simplify host status to online/offline and keep it live without a refresh --- RELEASE_NOTES.md | 9 +- packages/plugin-sdk/src/backend.ts | 16 +- packages/plugin-sdk/src/frontend.ts | 8 +- .../tests/backend/maintenance.test.ts | 2 +- plugins/docker/src/backend/connect.ts | 8 +- plugins/docker/tests/backend/routes.test.ts | 8 +- .../src/frontend/widgets/HostGridWidget.tsx | 8 +- .../src/frontend/widgets/HostStatusWidget.tsx | 17 +- plugins/host-metrics/src/backend/poller.ts | 13 +- plugins/host-metrics/src/backend/state.ts | 5 +- .../host-metrics/tests/backend/poller.test.ts | 16 +- .../host-metrics/tests/backend/state.test.ts | 1 - .../src/frontend/NetworkGraphCard.tsx | 4 +- .../database/routes/host-status-routes.ts | 2 +- .../hosts/status/host-status-service.ts | 174 ++++++----- src/backend/hosts/status/host-status.ts | 18 +- src/backend/hosts/status/jump-probe-pool.ts | 16 +- src/backend/hosts/status/tcp-ping.ts | 36 ++- src/backend/plugins/ctx-ssh-auth.ts | 21 +- .../routes/host-status-routes.test.ts | 2 +- .../hosts/status/host-status-service.test.ts | 81 ++---- .../tests/hosts/status/host-status.test.ts | 27 +- .../hosts/status/jump-probe-pool.test.ts | 23 ++ src/backend/tests/plugins/ctx-hosts.test.ts | 12 +- .../tests/plugins/ctx-ssh-auth.test.ts | 9 +- src/types/ui-types.ts | 2 +- src/ui/api/host-status-api.ts | 45 ++- src/ui/api/ssh-host-management-api.ts | 36 +-- src/ui/dashboard/DashboardTab.tsx | 34 ++- src/ui/hooks/use-status-color-scheme.ts | 10 +- src/ui/lib/ServerStatusContext.tsx | 274 ++++++------------ src/ui/lib/server-status-store.ts | 99 +------ src/ui/locales/en.json | 7 +- src/ui/main-axios.ts | 5 +- src/ui/plugin-host/bridge.ts | 22 +- src/ui/shell/CommandPalette.tsx | 6 +- src/ui/sidebar/AdminSettingsPanel.tsx | 2 +- src/ui/sidebar/HostsPanel.tsx | 19 +- src/ui/sidebar/live-host-status.ts | 36 +++ src/ui/sidebar/tree/FolderItem/FolderItem.tsx | 31 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 39 +-- src/ui/sidebar/tree/SidebarTree.tsx | 2 +- src/ui/tests/api/host-status-api.test.ts | 17 +- .../hooks/use-status-color-scheme.test.ts | 6 +- src/ui/tests/lib/server-status-store.test.ts | 48 +-- src/ui/tests/sidebar/live-host-status.test.ts | 41 +++ .../sidebar/tree/HostItem/HostItem.test.tsx | 3 +- .../tree/HostItem/host-status-tooltip.test.ts | 18 +- 48 files changed, 549 insertions(+), 789 deletions(-) create mode 100644 src/ui/sidebar/live-host-status.ts create mode 100644 src/ui/tests/sidebar/live-host-status.test.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1d63ad78d..3bdca37e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -17,6 +17,8 @@ https://youtu.be/lngaePO96tM - Added a reload button when a sign-in verification method fails to load - Admins can now merge a duplicate SSO account made by 2.9.0 back into the original account - Hosts behind the same jump hosts now share one connection for status checks +- Simplified host status to just online or offline, removing the "last login failed" state +- Host status now updates every 15 seconds without a page refresh, and status checks run every 30 seconds by default - Completed the Simplified Chinese translation - Documented cookie and API key sign-in in the API docs @@ -43,7 +45,12 @@ https://youtu.be/lngaePO96tM - Too many database writes from session activity - Host editor tabs being hidden when they did not fit - SSH host keys being accepted without a check when the host was missing from the database -- The "last login failed" host status showing in English or mistranslated in several languages +- Host status only updating after a full page refresh +- Every host showing offline right after signing in +- Host status flipping between online and offline on refresh or after one dropped packet +- Hosts showing "last login failed" when they connected fine +- Hosts behind a jump host staying offline after the jump connection dropped +- Host status being wiped in the desktop app when the host list failed to load - Tunnels, remote desktop logins and other host settings missing from shared hosts in the desktop app - SSO and LDAP provider dialogs overflowing the screen and using mismatched toggles - Plugin audit log entries failing to save when no user was signed in diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index d012d966b..660bad272 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -840,19 +840,24 @@ export interface PluginHosts { status: PluginHostStatusApi; } -/** online: a login worked. reachable: the port answers. offline: it does not. */ +/** + * online: the port answers, a session is open or a login worked. offline: the + * port does not answer. "reachable" is no longer sent and stays only so older + * plugins still type check. + */ export type PluginHostStatus = "online" | "reachable" | "offline"; export interface PluginHostStatusEntry { status: PluginHostStatus; lastChecked: string; + /** @deprecated No longer sent. */ reason?: "host_key_changed"; } /** * Core checks every host with status checks on by opening a TCP connection to - * it. A plugin that logs in to hosts tells core how that went, which is what - * turns "reachable" into "online". + * it. A plugin that logs in to hosts can report a working login, which marks + * the host online right away. */ export interface PluginHostStatusApi { /** @@ -862,7 +867,10 @@ export interface PluginHostStatusApi { get: (hostId: number) => Promise; /** Checks the host now unless core checked it recently. */ check: (hostId: number) => Promise; - /** Reports a login attempt. Not audited: pollers call it every sample. */ + /** + * Reports a login attempt. Only a working login changes the status; a + * failed one is ignored. Not audited: pollers call it every sample. + */ reportLogin: ( hostId: number, outcome: { ok: boolean; hostKeyChanged?: boolean }, diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index fa92480aa..109373cac 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -76,7 +76,7 @@ export interface PluginHostRecord { quickConnectSavable?: boolean; /** Assigned when a host is opened in a tab; tells duplicate tabs apart. */ instanceId?: string; - /** Core's status: "online" once a login worked. */ + /** Core's status: "online" when the host answers. */ status?: "online" | "reachable" | "offline" | "unknown"; online?: boolean; /** Someone else owns this host and shared it with the user. */ @@ -1134,11 +1134,13 @@ export interface ActivityTargetInfo { } /** - * Core's status for a host, the one behind the host list's dot. online: a - * login worked. reachable: the port answers. unknown: not checked yet. + * Core's status for a host, the one behind the host list's dot. online: the + * port answers. offline: it does not. unknown: not checked yet. "reachable" + * is no longer sent and stays only so older plugins still type check. */ export interface HostStatusInfo { status: "online" | "reachable" | "offline" | "unknown"; + /** @deprecated No longer sent. */ reason?: "host_key_changed"; } diff --git a/plugins/automations/tests/backend/maintenance.test.ts b/plugins/automations/tests/backend/maintenance.test.ts index 3730aa1a2..ddcef56cb 100644 --- a/plugins/automations/tests/backend/maintenance.test.ts +++ b/plugins/automations/tests/backend/maintenance.test.ts @@ -89,7 +89,7 @@ describe("host maintenance", () => { await service.tick(); expect(server.mock.notifications).toHaveLength(0); server.mock.hostStatuses.set(1, { - status: "reachable", + status: "online", lastChecked: "2026-01-01T13:21Z", }); await service.tick(); diff --git a/plugins/docker/src/backend/connect.ts b/plugins/docker/src/backend/connect.ts index bacbad75c..5501fef3a 100644 --- a/plugins/docker/src/backend/connect.ts +++ b/plugins/docker/src/backend/connect.ts @@ -341,13 +341,7 @@ export async function startConnect( return; } - const { hostKeyChanged, authFailed } = classifyConnectError( - message, - logs, - ); - if (hostKeyChanged || authFailed) { - ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged }); - } + const { authFailed } = classifyConnectError(message, logs); log.error("Docker SSH connection failed", { hostId, userId, diff --git a/plugins/docker/tests/backend/routes.test.ts b/plugins/docker/tests/backend/routes.test.ts index f317be6f3..21641a10a 100644 --- a/plugins/docker/tests/backend/routes.test.ts +++ b/plugins/docker/tests/backend/routes.test.ts @@ -303,7 +303,7 @@ describe("docker routes", () => { }); }); - it("reports a failed login to core", async () => { + it("never reports a failed login to core", async () => { const client = new FakeClient(); server = await startServer({ client }); server.mock.ctx.ssh.connect = (async () => { @@ -311,10 +311,6 @@ describe("docker routes", () => { }) as never; const response = await connect(server); expect(response.status).toBe(500); - expect(server.mock.statusReports).toContainEqual({ - hostId: 7, - ok: false, - hostKeyChanged: false, - }); + expect(server.mock.statusReports).toEqual([]); }); }); diff --git a/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx b/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx index 0a5e71372..4fb82b65b 100644 --- a/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx +++ b/plugins/homepage/src/frontend/widgets/HostGridWidget.tsx @@ -23,11 +23,9 @@ function StatusDot({ status }: { status: string }) { const color = status === "online" ? getAccentColor() - : status === "reachable" - ? "#fbbf24" - : status === "offline" - ? "#ef4444" - : "#6b7280"; + : status === "offline" + ? "#ef4444" + : "#6b7280"; return ( diff --git a/plugins/host-metrics/src/backend/poller.ts b/plugins/host-metrics/src/backend/poller.ts index 5a78653c2..0abdbf56b 100644 --- a/plugins/host-metrics/src/backend/poller.ts +++ b/plugins/host-metrics/src/backend/poller.ts @@ -347,11 +347,8 @@ export class MetricsPoller { const settings = await this.settingsFor(host.id); if (!settings.metricsEnabled) return; - let authenticated = false; try { - const metrics = await collectMetrics(this.deps, host, settings, () => { - authenticated = true; - }); + const metrics = await collectMetrics(this.deps, host, settings); this.metrics.set(host.id, { data: metrics, timestamp: Date.now() }); this.ctx.hosts.status.reportLogin(host.id, { ok: true }); this.setCollectionState(host, "collecting"); @@ -367,14 +364,6 @@ export class MetricsPoller { const hostKeyChanged = isHostKeyVerificationError(error); const message = error instanceof Error ? error.message : ""; const isAuthError = /authentication|permission denied/i.test(message); - - // A timeout or a dropped connection says nothing about the login. - if (!authenticated && (isAuthError || hostKeyChanged)) { - this.ctx.hosts.status.reportLogin(host.id, { - ok: false, - hostKeyChanged, - }); - } this.setCollectionState( host, hostKeyChanged diff --git a/plugins/host-metrics/src/backend/state.ts b/plugins/host-metrics/src/backend/state.ts index 1816a778b..9e6d6a144 100644 --- a/plugins/host-metrics/src/backend/state.ts +++ b/plugins/host-metrics/src/backend/state.ts @@ -1,8 +1,7 @@ +import type { PluginHostStatus } from "@termix/plugin-sdk/backend"; import type { CpuSamples } from "./widgets/cpu-collector.js"; import { createNetworkSamples } from "./widgets/network-collector.js"; -export type HostStatus = "online" | "reachable" | "offline"; - export class RequestQueue { private queues = new Map Promise>>(); private processing = new Set(); @@ -418,7 +417,7 @@ export class HostPollCache { * host answers. A host core does not check (status checks off) is tried. */ export function canStartInitialMetrics( - status: HostStatus | null | undefined, + status: PluginHostStatus | null | undefined, hasViewers: boolean, ): boolean { return hasViewers && status !== "offline"; diff --git a/plugins/host-metrics/tests/backend/poller.test.ts b/plugins/host-metrics/tests/backend/poller.test.ts index 8814545f6..a0de9543d 100644 --- a/plugins/host-metrics/tests/backend/poller.test.ts +++ b/plugins/host-metrics/tests/backend/poller.test.ts @@ -40,7 +40,7 @@ function setup(hostIds = [7]) { hostStatuses: Object.fromEntries( hostIds.map((id) => [ id, - { status: "reachable" as const, lastChecked: "now" }, + { status: "online" as const, lastChecked: "now" }, ]), ), }); @@ -131,13 +131,13 @@ describe("MetricsPoller", () => { expect(collectMetrics).not.toHaveBeenCalled(); // Core's check comes back: the first sample runs. - fake.hostStatuses.set(7, { status: "reachable", lastChecked: "now" }); - poller.hostStatusChanged(7, "reachable"); + fake.hostStatuses.set(7, { status: "online", lastChecked: "now" }); + poller.hostStatusChanged(7, "online"); await flush(); expect(collectMetrics).toHaveBeenCalledOnce(); }); - it("reports a failed login and why collection stopped", async () => { + it("says why collection stopped without touching the status", async () => { collectMetrics.mockRejectedValue( new Error("All configured authentication methods failed"), ); @@ -146,9 +146,7 @@ describe("MetricsPoller", () => { poller.registerViewer(7, "viewer-a", "user-1"); await flush(); - expect(fake.statusReports).toEqual([ - { hostId: 7, ok: false, hostKeyChanged: false }, - ]); + expect(fake.statusReports).toEqual([]); expect(fake.emitted.at(-1)?.payload).toMatchObject({ state: "auth_failed", }); @@ -174,9 +172,7 @@ describe("MetricsPoller", () => { poller.registerViewer(7, "viewer-a", "user-1"); await flush(); - expect(fake.statusReports).toEqual([ - { hostId: 7, ok: false, hostKeyChanged: true }, - ]); + expect(fake.statusReports).toEqual([]); expect(state.authFailures.shouldSkip(7)).toBe(true); poller.hostKeyAccepted(7); expect(state.authFailures.shouldSkip(7)).toBe(false); diff --git a/plugins/host-metrics/tests/backend/state.test.ts b/plugins/host-metrics/tests/backend/state.test.ts index e5c157f30..246f778e8 100644 --- a/plugins/host-metrics/tests/backend/state.test.ts +++ b/plugins/host-metrics/tests/backend/state.test.ts @@ -27,7 +27,6 @@ describe("auth failure tracking", () => { describe("initial metrics admission", () => { it("needs a viewer and a host core does not see as offline", () => { expect(canStartInitialMetrics("online", true)).toBe(true); - expect(canStartInitialMetrics("reachable", true)).toBe(true); expect(canStartInitialMetrics("offline", true)).toBe(false); expect(canStartInitialMetrics("online", false)).toBe(false); // Status checks off: core has no status, so the sample is tried. diff --git a/plugins/network-topology/src/frontend/NetworkGraphCard.tsx b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx index 8d0b5f440..f08a719e7 100644 --- a/plugins/network-topology/src/frontend/NetworkGraphCard.tsx +++ b/plugins/network-topology/src/frontend/NetworkGraphCard.tsx @@ -73,7 +73,7 @@ const AVAILABLE_COLORS = [ { value: "#6b7280", label: "Gray" }, ]; -type HostStatus = "online" | "reachable" | "offline" | "unknown"; +type HostStatus = "online" | "offline" | "unknown"; interface HostWithStatus extends PluginHostRecord { status?: HostStatus; @@ -184,8 +184,6 @@ function renderNodeSvg( statusColor = useRealColors ? "rgb(16,185,129)" : resolveCssVar("--accent-brand", "rgb(16,185,129)"); - } else if (status === "reachable") { - statusColor = "rgb(251,191,36)"; } else if (isOffline) { statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)"; } else { diff --git a/src/backend/database/routes/host-status-routes.ts b/src/backend/database/routes/host-status-routes.ts index 4faf49ef6..10bdb139a 100644 --- a/src/backend/database/routes/host-status-routes.ts +++ b/src/backend/database/routes/host-status-routes.ts @@ -46,7 +46,7 @@ export function registerHostStatusRoutes( * /host/status: * get: * summary: Get host statuses - * description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call. + * description: Returns the online or offline status of every host the user can see. Starts status checks for the user's own hosts on first call and waits briefly for their first results. * tags: * - SSH * parameters: diff --git a/src/backend/hosts/status/host-status-service.ts b/src/backend/hosts/status/host-status-service.ts index c238dfd95..96d47b976 100644 --- a/src/backend/hosts/status/host-status-service.ts +++ b/src/backend/hosts/status/host-status-service.ts @@ -2,18 +2,16 @@ * The host list's status dot. * * Every host with status checks on gets a TCP connection to its port on a - * timer. A port that answers is "online", one that does not is "offline". - * "reachable" is the warning in between: the port answers but the last login - * failed (a session, a ctx.ssh connection or a plugin's - * ctx.hosts.status.reportLogin said so). It clears on the next working login, - * when the host is edited, or when a changed host key is accepted. Probing - * never logs in to the host itself: on RADIUS or Duo backed devices a login - * fires a real 2FA push, every interval. A host behind jump hosts is probed - * through a chain, which does log in to the hops, so hosts that share a hop - * list share one chain (jump-probe-pool.ts). + * timer. A port that answers is "online", one that does not (twice in a row) + * is "offline". An open session or a working login also counts as online. + * Probing never logs in to the host itself: on RADIUS or Duo backed devices a + * login fires a real 2FA push, every interval. A host behind jump hosts is + * probed through a chain, which does log in to the hops, so hosts that share + * a hop list share one chain (jump-probe-pool.ts). * * Polling is demand driven. A user asking for statuses starts polling their - * own hosts; nothing is probed for a user who never opened the app. + * own hosts; nothing is probed for a user who never opened the app. The first + * request waits briefly for those first checks so it is not empty. */ import { pluginEvents, TOPICS } from "../../plugins/events.js"; @@ -24,24 +22,21 @@ import { getCurrentSettingValue, } from "../../database/repositories/factory.js"; import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js"; -import { - statusAfterAuthentication, - statusAfterReachabilityCheck, - type HostStatus, -} from "./host-status.js"; +import type { HostStatus } from "./host-status.js"; import { ConcurrentLimiter } from "./limiter.js"; import { tcpPing } from "./tcp-ping.js"; import { JumpProbePool } from "./jump-probe-pool.js"; export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval"; -export const DEFAULT_STATUS_INTERVAL = 60; +export const DEFAULT_STATUS_INTERVAL = 30; +/** How long the first request for a user waits for the first checks. */ +const FIRST_CHECK_WAIT_MS = 6_000; /** A status younger than this is fresh enough for check(). */ const FRESH_MS = 30_000; export interface HostStatusEntry { status: HostStatus; lastChecked: string; - reason?: "host_key_changed"; } export interface HostStatusPayload { @@ -51,7 +46,6 @@ export interface HostStatusPayload { previous: HostStatus | null; /** Anything but offline, the shape automations' trigger already reads. */ online: boolean; - reason?: "host_key_changed"; } export interface StatusTarget { @@ -84,6 +78,8 @@ export interface HostStatusDeps { hasActiveSession?: (hostId: number) => boolean; globalInterval: () => number; emit: (payload: HostStatusPayload) => void; + /** Pause before a failed ping is tried again. */ + retryDelayMs?: number; } function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { @@ -158,7 +154,6 @@ export class HostStatusService { private readonly store = new Map(); private readonly owners = new Map(); private readonly inFlight = new Map>(); - private readonly loginFailed = new Set(); private readonly startedUsers = new Set(); private readonly portResolvers = new Map>(); private readonly limiter = new ConcurrentLimiter(20); @@ -175,19 +170,12 @@ export class HostStatusService { }), pluginEvents.on(TOPICS.hostUpdated, (payload) => { const { hostId } = payload as { hostId?: number }; - if (!hostId) return; - // An edit may have fixed the login, so the next attempt decides. - this.loginFailed.delete(hostId); - void this.reload(hostId); + if (hostId) void this.reload(hostId); }), pluginEvents.on(TOPICS.hostDeleted, (payload) => { const { hostId } = payload as { hostId?: number }; if (hostId) this.forget(hostId); }), - pluginEvents.on(TOPICS.hostKeyUpdated, (payload) => { - const { hostId } = payload as { hostId?: number }; - if (hostId) this.clearHostKeyReason(hostId); - }), ]; } @@ -212,11 +200,13 @@ export class HostStatusService { userId: string, requestedHostIds: Set | null, ): Promise> { + const started: Promise[] = []; if (requestedHostIds !== null) { - await this.reconcile(userId, requestedHostIds); + await this.reconcile(userId, requestedHostIds, started); } else if (!this.startedUsers.has(userId)) { - await this.startUser(userId); + await this.startUser(userId, started); } + if (started.length > 0) await this.waitFor(started); return this.store; } @@ -249,20 +239,12 @@ export class HostStatusService { return this.store.get(hostId) ?? null; } - reportLogin( - hostId: number, - outcome: { ok: boolean; hostKeyChanged?: boolean }, - ): void { - if (outcome.ok) { - this.loginFailed.delete(hostId); - this.set(hostId, { status: statusAfterAuthentication(true) }); - return; - } - this.loginFailed.add(hostId); - this.set(hostId, { - status: statusAfterAuthentication(false, this.store.get(hostId)?.status), - ...(outcome.hostKeyChanged ? { reason: "host_key_changed" } : {}), - }); + /** + * A working login proves the host is up. A failed one says nothing about + * reachability (a wrong password, another user's login), so it is ignored. + */ + reportLogin(hostId: number, outcome: { ok: boolean }): void { + if (outcome.ok) this.set(hostId, { status: "online" }); } registerPort(connectionType: string, resolve: PortResolver): () => void { @@ -278,13 +260,33 @@ export class HostStatusService { }; } - private async startUser(userId: string): Promise { + private async waitFor(started: Promise[]): Promise { + let timer: NodeJS.Timeout | undefined; + await Promise.race([ + Promise.allSettled(started), + new Promise((resolve) => { + timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS); + timer.unref?.(); + }), + ]); + if (timer) clearTimeout(timer); + } + + private track(started: Promise[], target: StatusTarget): void { + const probe = this.startPolling(target); + if (probe) started.push(probe); + } + + private async startUser( + userId: string, + started: Promise[], + ): Promise { this.startedUsers.add(userId); const targets = await this.deps.loadTargets({ userId }); for (const target of targets) { - if (!this.polled.has(target.id)) this.startPolling(target); + if (!this.polled.has(target.id)) this.track(started, target); } - await this.startShared(userId, null); + await this.startShared(userId, null, started); } /** @@ -294,6 +296,7 @@ export class HostStatusService { private async startShared( userId: string, allowed: Set | null, + started: Promise[], ): Promise { if (!this.deps.loadSharedHostIds) return; try { @@ -304,7 +307,7 @@ export class HostStatusService { if (shared.length === 0) return; const targets = await this.deps.loadTargets({ hostIds: shared }); for (const target of targets) { - if (!this.polled.has(target.id)) this.startPolling(target); + if (!this.polled.has(target.id)) this.track(started, target); } } catch (error) { sshLogger.warn("Could not start status checks for shared hosts", { @@ -315,7 +318,11 @@ export class HostStatusService { } } - private async reconcile(userId: string, allowed: Set): Promise { + private async reconcile( + userId: string, + allowed: Set, + started: Promise[], + ): Promise { const targets = await this.deps.loadTargets({ userId }); for (const target of targets) { if (!allowed.has(target.id)) { @@ -323,9 +330,9 @@ export class HostStatusService { this.store.delete(target.id); continue; } - if (!this.polled.has(target.id)) this.startPolling(target); + if (!this.polled.has(target.id)) this.track(started, target); } - await this.startShared(userId, allowed); + await this.startShared(userId, allowed, started); } private async reload(hostId: number): Promise { @@ -351,16 +358,6 @@ export class HostStatusService { this.stopPolling(hostId); this.store.delete(hostId); this.owners.delete(hostId); - this.loginFailed.delete(hostId); - } - - private clearHostKeyReason(hostId: number): void { - const current = this.store.get(hostId); - if (current?.reason !== "host_key_changed") return; - this.loginFailed.delete(hostId); - this.set(hostId, { - status: current.status === "offline" ? "offline" : "online", - }); } private intervalMs(target: StatusTarget): number { @@ -373,21 +370,26 @@ export class HostStatusService { ); } - private startPolling(target: StatusTarget, probeNow = true): void { + /** Returns the first probe when one was started. */ + private startPolling( + target: StatusTarget, + probeNow = true, + ): Promise | undefined { this.stopPolling(target.id); this.owners.set(target.id, target.userId); if (!target.statusCheckEnabled) { this.store.delete(target.id); - return; + return undefined; } const polled: Polled = { target }; this.polled.set(target.id, polled); - if (probeNow) void this.probe(target); + const first = probeNow ? this.probe(target) : undefined; polled.timer = setInterval(() => { const latest = this.polled.get(target.id); if (latest) void this.probe(latest.target); }, this.intervalMs(target)); polled.timer.unref?.(); + return first; } private stopPolling(hostId: number): void { @@ -427,33 +429,28 @@ export class HostStatusService { return target.port; } + private async pingOnce(target: StatusTarget): Promise { + try { + if (this.deps.hasActiveSession?.(target.id)) return true; + const port = await this.portFor(target); + return target.jumpHosts.length > 0 + ? await this.deps.pingThroughJumpHosts(target, port) + : await this.deps.ping(target.ip, port); + } catch { + return false; + } + } + private async probeNow(target: StatusTarget): Promise { this.owners.set(target.id, target.userId); - let reachable = false; - try { - if (this.deps.hasActiveSession?.(target.id)) { - reachable = true; - } else { - const port = await this.portFor(target); - reachable = - target.jumpHosts.length > 0 - ? await this.deps.pingThroughJumpHosts(target, port) - : await this.deps.ping(target.ip, port); - } - } catch { - reachable = false; + let reachable = await this.pingOnce(target); + if (!reachable) { + // One lost packet should not flip the dot. + const delay = this.deps.retryDelayMs ?? 1000; + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)); + reachable = await this.pingOnce(target); } - - const current = this.store.get(target.id); - this.set(target.id, { - status: statusAfterReachabilityCheck( - reachable, - this.loginFailed.has(target.id), - ), - ...(reachable && current?.reason === "host_key_changed" - ? { reason: "host_key_changed" as const } - : {}), - }); + this.set(target.id, { status: reachable ? "online" : "offline" }); } private set(hostId: number, entry: Omit) { @@ -486,8 +483,7 @@ export class HostStatusService { ownerUserId, status: entry.status, previous, - online: entry.status !== "offline", - ...(entry.reason ? { reason: entry.reason } : {}), + online: entry.status === "online", }); } } diff --git a/src/backend/hosts/status/host-status.ts b/src/backend/hosts/status/host-status.ts index de2d9eaf6..5e045910a 100644 --- a/src/backend/hosts/status/host-status.ts +++ b/src/backend/hosts/status/host-status.ts @@ -1,4 +1,4 @@ -export type HostStatus = "online" | "reachable" | "offline"; +export type HostStatus = "online" | "offline"; export function isHostKeyVerificationError(error: unknown): boolean { return ( @@ -7,19 +7,3 @@ export function isHostKeyVerificationError(error: unknown): boolean { error.message.includes("Host key changed")) ); } - -export function statusAfterReachabilityCheck( - reachable: boolean, - loginFailed: boolean, -): HostStatus { - if (!reachable) return "offline"; - return loginFailed ? "reachable" : "online"; -} - -export function statusAfterAuthentication( - authenticated: boolean, - current?: HostStatus, -): HostStatus { - if (authenticated) return "online"; - return current === "offline" ? "offline" : "reachable"; -} diff --git a/src/backend/hosts/status/jump-probe-pool.ts b/src/backend/hosts/status/jump-probe-pool.ts index a9e2459ef..15cd744f1 100644 --- a/src/backend/hosts/status/jump-probe-pool.ts +++ b/src/backend/hosts/status/jump-probe-pool.ts @@ -1,5 +1,5 @@ import type { Client } from "ssh2"; -import { tcpPingThroughJumpHost } from "./tcp-ping.js"; +import { tcpPingThroughJumpHostResult } from "./tcp-ping.js"; type OpenChain = ( jumpHosts: Array<{ hostId: number }>, @@ -44,7 +44,19 @@ export class JumpProbePool { return false; } this.touch(key, entry, client); - return tcpPingThroughJumpHost(client, host, port, timeoutMs, true); + const result = await tcpPingThroughJumpHostResult( + client, + host, + port, + timeoutMs, + true, + ); + if (result === "timeout") { + // The hop never answered, so the next ping opens a fresh chain. + this.drop(key, entry); + client.end(); + } + return result === "ok"; } closeAll(): void { diff --git a/src/backend/hosts/status/tcp-ping.ts b/src/backend/hosts/status/tcp-ping.ts index 69ea46144..64b8636ec 100644 --- a/src/backend/hosts/status/tcp-ping.ts +++ b/src/backend/hosts/status/tcp-ping.ts @@ -64,18 +64,23 @@ export function tcpPing( }); } -/** The same check from the far end of a jump host chain. Ends the chain. */ -export function tcpPingThroughJumpHost( +/** + * "refused" means the last hop answered that the target is down. "timeout" + * means the hop never answered, so the chain itself may be dead. + */ +export type JumpPingResult = "ok" | "refused" | "timeout"; + +export function tcpPingThroughJumpHostResult( jumpClient: Pick, host: string, port: number, timeoutMs = 5000, keepOpen = false, -): Promise { +): Promise { return new Promise((resolve) => { let settled = false; - const finish = (result: boolean) => { + const finish = (result: JumpPingResult) => { if (settled) return; settled = true; clearTimeout(timeout); @@ -83,11 +88,30 @@ export function tcpPingThroughJumpHost( resolve(result); }; - const timeout = setTimeout(() => finish(false), timeoutMs); + const timeout = setTimeout(() => finish("timeout"), timeoutMs); jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => { stream?.destroy(); - finish(!error && !!stream); + finish(!error && !!stream ? "ok" : "refused"); }); }); } + +/** The same check from the far end of a jump host chain. Ends the chain. */ +export async function tcpPingThroughJumpHost( + jumpClient: Pick, + host: string, + port: number, + timeoutMs = 5000, + keepOpen = false, +): Promise { + return ( + (await tcpPingThroughJumpHostResult( + jumpClient, + host, + port, + timeoutMs, + keepOpen, + )) === "ok" + ); +} diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index ec62538cf..c09022ec1 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -33,7 +33,6 @@ import { } from "../hosts/connect/auth-provider-registry.js"; import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js"; import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; -import { isHostKeyVerificationError } from "../hosts/status/host-status.js"; import { getLoginMethod, registerLoginMethod, @@ -124,9 +123,6 @@ function withOverrides( return merged as unknown as SshConnectHost; } -const LOGIN_FAILED = - /all configured authentication methods failed|permission denied|authentication failed/i; - export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { const pluginId = manifest.id; const declared = manifest.capabilities; @@ -223,23 +219,13 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { return { ...(host as unknown as SshConnectHost), userId }; }; - /** - * A login to a saved host core resolved feeds its status dot. Only a clear - * auth or host key failure counts: a timeout says nothing about the login. - */ - const reportLogin = (host: number | PluginSshHost, error?: unknown) => { + /** A working login to a saved host core resolved marks it online. */ + const reportLogin = (host: number | PluginSshHost) => { const hostId = typeof host === "number" ? host : lookup(host)?.host.id; if (!Number.isInteger(hostId) || hostId <= 0) return; - let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true }; - if (error !== undefined) { - const message = error instanceof Error ? error.message : String(error); - const hostKeyChanged = isHostKeyVerificationError(error); - if (!hostKeyChanged && !LOGIN_FAILED.test(message)) return; - outcome = { ok: false, hostKeyChanged }; - } void import("../hosts/status/host-status-service.js") .then(({ hostStatusService }) => - hostStatusService.reportLogin(hostId, outcome), + hostStatusService.reportLogin(hostId, { ok: true }), ) .catch(() => {}); }; @@ -299,7 +285,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh { success: false, errorMessage: error instanceof Error ? error.message : String(error), }); - reportLogin(host, error); throw error; } }; diff --git a/src/backend/tests/database/routes/host-status-routes.test.ts b/src/backend/tests/database/routes/host-status-routes.test.ts index c7990cc92..4611b210e 100644 --- a/src/backend/tests/database/routes/host-status-routes.test.ts +++ b/src/backend/tests/database/routes/host-status-routes.test.ts @@ -88,7 +88,7 @@ beforeEach(() => { state.statuses = new Map([ [1, { status: "online", lastChecked: "t" }], [2, { status: "offline", lastChecked: "t" }], - [3, { status: "reachable", lastChecked: "t" }], + [3, { status: "offline", lastChecked: "t" }], ]); service.statusesFor.mockImplementation(async () => state.statuses); service.get.mockImplementation( diff --git a/src/backend/tests/hosts/status/host-status-service.test.ts b/src/backend/tests/hosts/status/host-status-service.test.ts index c5aa28214..0d3eb1d49 100644 --- a/src/backend/tests/hosts/status/host-status-service.test.ts +++ b/src/backend/tests/hosts/status/host-status-service.test.ts @@ -55,6 +55,7 @@ function setup( hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id), globalInterval: () => 60, emit: (payload) => emitted.push(payload), + retryDelayMs: 0, }); return { service, emitted, ping, pingThroughJumpHosts, loadTargets }; } @@ -192,86 +193,44 @@ describe("HostStatusService", () => { expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389); }); - it("warns about a failed login until a login works again", async () => { + it("ignores a failed login and counts a working one as online", async () => { const { service, emitted } = setup([target(1)]); active = service; await service.statusesFor("owner", null); - await flush(); + const before = emitted.length; service.reportLogin(1, { ok: false }); - expect(service.get(1)?.status).toBe("reachable"); - expect(emitted.at(-1)).toMatchObject({ - status: "reachable", - previous: "online", - }); - - vi.advanceTimersByTime(75_000); - await flush(); - expect(service.get(1)?.status).toBe("reachable"); - - service.reportLogin(1, { ok: true }); - expect(service.get(1)?.status).toBe("online"); - vi.advanceTimersByTime(75_000); - await flush(); expect(service.get(1)?.status).toBe("online"); + expect(emitted).toHaveLength(before); }); - it("marks a changed host key and clears it once the new key is accepted", async () => { - const { service } = setup([target(1)]); + it("marks a host online when a terminal session opens", async () => { + const { service } = setup([target(1)], false); active = service; service.start(); await service.statusesFor("owner", null); - await flush(); - service.reportLogin(1, { ok: false, hostKeyChanged: true }); - expect(service.get(1)).toMatchObject({ - status: "reachable", - reason: "host_key_changed", - }); - - pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 }); - expect(service.get(1)?.reason).toBeUndefined(); - expect(service.get(1)?.status).toBe("online"); - }); - - it("clears a failed login when the host is edited", async () => { - const { service } = setup([target(1)]); - active = service; - service.start(); - await service.statusesFor("owner", null); - await flush(); - service.reportLogin(1, { ok: false }); - - pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 }); - await flush(); - await flush(); - expect(service.get(1)?.status).toBe("online"); - }); - - it("clears a failed login when a terminal session opens", async () => { - const { service } = setup([target(1)]); - active = service; - service.start(); - await service.statusesFor("owner", null); - await flush(); - service.reportLogin(1, { ok: false }); + expect(service.get(1)?.status).toBe("offline"); const release = hostSessionStatus.register(1); expect(service.get(1)?.status).toBe("online"); release(); + }); + + it("tries a failed ping again before reporting offline", async () => { + const { service, ping } = setup([target(1)]); + active = service; + ping.mockResolvedValueOnce(false); + await service.statusesFor("owner", null); + expect(ping).toHaveBeenCalledTimes(2); expect(service.get(1)?.status).toBe("online"); }); - it("shows an offline host as offline whatever the login said", async () => { - const { service } = setup([target(1)], false); + it("answers the first request with the first checks done", async () => { + const { service } = setup([target(1), target(2)]); active = service; - await service.statusesFor("owner", null); - await flush(); - service.reportLogin(1, { ok: false }); - expect(service.get(1)?.status).toBe("offline"); - service.reportLogin(1, { ok: true }); - vi.advanceTimersByTime(75_000); - await flush(); - expect(service.get(1)?.status).toBe("offline"); + const statuses = await service.statusesFor("owner", null); + expect(statuses.get(1)?.status).toBe("online"); + expect(statuses.get(2)?.status).toBe("online"); }); it("only checks the requested hosts on the desktop app", async () => { diff --git a/src/backend/tests/hosts/status/host-status.test.ts b/src/backend/tests/hosts/status/host-status.test.ts index 2509ddbf2..1d67f7a12 100644 --- a/src/backend/tests/hosts/status/host-status.test.ts +++ b/src/backend/tests/hosts/status/host-status.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - isHostKeyVerificationError, - statusAfterAuthentication, - statusAfterReachabilityCheck, -} from "../../../hosts/status/host-status.js"; +import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js"; describe("isHostKeyVerificationError", () => { it.each([ @@ -19,24 +15,3 @@ describe("isHostKeyVerificationError", () => { ); }); }); - -describe("host availability status", () => { - it("calls a host whose port answers online", () => { - expect(statusAfterReachabilityCheck(true, false)).toBe("online"); - expect(statusAfterReachabilityCheck(false, false)).toBe("offline"); - }); - - it("warns about a reachable host whose last login failed", () => { - expect(statusAfterReachabilityCheck(true, true)).toBe("reachable"); - expect(statusAfterReachabilityCheck(false, true)).toBe("offline"); - }); - - it("marks successful authentication online", () => { - expect(statusAfterAuthentication(true, "reachable")).toBe("online"); - }); - - it("downgrades failed authentication without hiding reachability", () => { - expect(statusAfterAuthentication(false, "online")).toBe("reachable"); - expect(statusAfterAuthentication(false, "offline")).toBe("offline"); - }); -}); diff --git a/src/backend/tests/hosts/status/jump-probe-pool.test.ts b/src/backend/tests/hosts/status/jump-probe-pool.test.ts index e788a12e1..8d5164834 100644 --- a/src/backend/tests/hosts/status/jump-probe-pool.test.ts +++ b/src/backend/tests/hosts/status/jump-probe-pool.test.ts @@ -67,6 +67,29 @@ describe("JumpProbePool", () => { pool.closeAll(); }); + it("drops a chain whose hop stops answering", async () => { + vi.useFakeTimers(); + const stale = Object.assign(new EventEmitter(), { + end: vi.fn(), + forwardOut: vi.fn(), + }); + const fresh = fakeClient(); + const open = vi + .fn() + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(fresh); + const pool = new JumpProbePool(open); + + const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000); + await vi.advanceTimersByTimeAsync(1000); + expect(await first).toBe(false); + expect(stale.end).toHaveBeenCalled(); + + expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true); + expect(open).toHaveBeenCalledTimes(2); + pool.closeAll(); + }); + it("retries a chain that failed to open", async () => { const open = vi .fn() diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index 95f5be69d..4dca60acc 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({ vi.mock("../../hosts/status/host-status-service.js", () => ({ hostStatusService: { get: (hostId: number) => - hostId === 4 ? { status: "reachable", lastChecked: "t" } : null, + hostId === 4 ? { status: "online", lastChecked: "t" } : null, check: async (hostId: number) => ({ status: "offline", lastChecked: String(hostId), @@ -496,7 +496,7 @@ describe("ctx.hosts", () => { } expect(record.sshOptions).toEqual({ keepaliveInterval: 5 }); expect(record.pluginSettings).toEqual({ mine: "own-4" }); - expect(record.status).toEqual({ status: "reachable", lastChecked: "t" }); + expect(record.status).toEqual({ status: "online", lastChecked: "t" }); expect(record.enableSsh).toBe(true); }); @@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => { audit: vi.fn(async () => {}), }); await expect(hosts.status.get(4)).resolves.toEqual({ - status: "reachable", + status: "online", lastChecked: "t", }); await expect(hosts.status.get(5)).resolves.toBeNull(); @@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => { manifest: manifest(["hosts:read"]), audit: vi.fn(async () => {}), }); - hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true }); + hosts.status.reportLogin(4, { ok: true }); await settle(); - expect(h.statusReports).toEqual([ - { hostId: 4, ok: false, hostKeyChanged: true }, - ]); + expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]); }); it("removes a registered port when the plugin is disposed", async () => { diff --git a/src/backend/tests/plugins/ctx-ssh-auth.test.ts b/src/backend/tests/plugins/ctx-ssh-auth.test.ts index 274589ea4..c735d1bc0 100644 --- a/src/backend/tests/plugins/ctx-ssh-auth.test.ts +++ b/src/backend/tests/plugins/ctx-ssh-auth.test.ts @@ -220,24 +220,19 @@ describe("ctx.ssh", () => { expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]); }); - it("reports an auth or host key failure, but not a timeout", async () => { + it("never reports a failed login to the status", async () => { h.granted = new Set(["ssh:connect", "credentials:use"]); const ssh = createPluginSsh({ manifest: manifest(["ssh:connect", "credentials:use"]), bag: new DisposableBag("fixture"), audit: vi.fn(async () => {}), }); - h.connectError = new Error("Timed out while waiting for handshake"); - await expect(ssh.connect(7)).rejects.toThrow(); h.connectError = new Error("All configured authentication methods failed"); await expect(ssh.connect(7)).rejects.toThrow(); h.connectError = new Error("Host key changed"); await expect(ssh.connect(8)).rejects.toThrow(); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(h.logins).toEqual([ - { hostId: 7, outcome: { ok: false, hostKeyChanged: false } }, - { hostId: 8, outcome: { ok: false, hostKeyChanged: true } }, - ]); + expect(h.logins).toEqual([]); }); it("never believes a host object's own userId", async () => { diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 33ea4e621..6350dd02a 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -25,7 +25,7 @@ export type Host = { */ childHosts?: Host[]; online: boolean; - status?: "online" | "reachable" | "offline" | "unknown"; + status?: "online" | "offline" | "unknown"; cpu: number | null; ram: number | null; lastAccess: string; diff --git a/src/ui/api/host-status-api.ts b/src/ui/api/host-status-api.ts index 1f67b61ed..41c908b08 100644 --- a/src/ui/api/host-status-api.ts +++ b/src/ui/api/host-status-api.ts @@ -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 > { @@ -71,31 +72,24 @@ export async function getAllServerStatuses(): Promise< let remoteHostsBySyncId: Map | null = null; if (isElectron()) { - try { - const response = await sshHostApi.get("/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("/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 { diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 2509852bd..0ebbfb4b3 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -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 { const hostsResponse = await sshHostApi.get("/db/host"); return Array.isArray(hostsResponse.data) ? hostsResponse.data : []; } -export async function getSSHHosts( - options: GetSSHHostsOptions = {}, -): Promise { - const includeStatus = options.includeStatus !== false; - +/** Host config only. Live status comes from ServerStatusContext. */ +export async function getSSHHosts(): Promise { try { const hosts = await getCachedSSHHosts(loadSSHHostsFromApi); - - if (!includeStatus) { - return hosts.map((host) => ({ - ...host, - status: "unknown", - })); - } - - let statuses: Record = {}; - 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"); } diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx index 0ebb3bc4e..f70508e0d 100644 --- a/src/ui/dashboard/DashboardTab.tsx +++ b/src/ui/dashboard/DashboardTab.tsx @@ -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({ - {online}/{hosts.length} {t("hosts.status.available")} + {online}/{hosts.length} {t("hosts.status.online")}
@@ -439,12 +443,7 @@ export function HostStatusCard({
)} {hosts.map((host, i) => { - const availability = - host.status && host.status !== "unknown" - ? host.status - : host.online - ? "online" - : "offline"; + const availability = host.status ?? "offline"; return (
- {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")}
@@ -582,7 +579,7 @@ function RecentActivityCard({ >
@@ -1102,7 +1099,14 @@ export function DashboardTab({ } }, [mainWidthPct]); - const [hosts, setHosts] = useState([]); + const [configHosts, setHosts] = useState([]); + 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(""); diff --git a/src/ui/hooks/use-status-color-scheme.ts b/src/ui/hooks/use-status-color-scheme.ts index 0af87ec8d..44711e2f1 100644 --- a/src/ui/hooks/use-status-color-scheme.ts +++ b/src/ui/hooks/use-status-color-scheme.ts @@ -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") diff --git a/src/ui/lib/ServerStatusContext.tsx b/src/ui/lib/ServerStatusContext.tsx index 99f437635..6985595da 100644 --- a/src/ui/lib/ServerStatusContext.tsx +++ b/src/ui/lib/ServerStatusContext.tsx @@ -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; - isLoading: boolean; initialLoadComplete: boolean; refreshStatuses: () => Promise; + /** "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(null); const ServerStatusContext = createContext(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 | null>(null); + const refreshInFlightRef = useRef | 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(); - } - - try { - const hosts = await getSSHHosts({ includeStatus: false }); - const enabled = new Set(); - - 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(); - 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(); + 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 }; } diff --git a/src/ui/lib/server-status-store.ts b/src/ui/lib/server-status-store.ts index 9c26a28e0..3b6157d86 100644 --- a/src/ui/lib/server-status-store.ts +++ b/src/ui/lib/server-status-store.ts @@ -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(); - private enabledHostIds = new Set(); private initialLoadComplete = false; - private isLoading = false; private readonly hostListeners = new Map>(); private readonly allListeners = new Set(); private readonly metaListeners = new Set(); + /** "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 { return this.statuses; } - getEnabledHostIds(): Set { - 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): 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): 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, b: Set): boolean { - if (a.size !== b.size) return false; - for (const id of a) { - if (!b.has(id)) return false; - } - return true; -} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 7173f984c..4bc6beefa 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 18607fa87..8c496f3d0 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -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 { diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts index d6b4a7681..39a668fcd 100644 --- a/src/ui/plugin-host/bridge.ts +++ b/src/ui/plugin-host/bridge.ts @@ -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")); }, diff --git a/src/ui/shell/CommandPalette.tsx b/src/ui/shell/CommandPalette.tsx index 614d8d22d..8f3f02271 100644 --- a/src/ui/shell/CommandPalette.tsx +++ b/src/ui/shell/CommandPalette.tsx @@ -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({ )} - {host.online && ( + {getLiveHostStatus(Number(host.id)) === + "online" && ( )}
diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 481b396e6..a816765ba 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -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([]); diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index a57671077..6dec58d26 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -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({
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[]; +} diff --git a/src/ui/sidebar/tree/FolderItem/FolderItem.tsx b/src/ui/sidebar/tree/FolderItem/FolderItem.tsx index f5b7c617a..6ebad7dd4 100644 --- a/src/ui/sidebar/tree/FolderItem/FolderItem.tsx +++ b/src/ui/sidebar/tree/FolderItem/FolderItem.tsx @@ -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; diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index 00f39ff0d..293be1774 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -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({ - {statusReason === "host_key_changed" - ? `${t("hostKey.keyChangedWarning")}: ${t("hostKey.keyChangedDescription")}` - : buildStatusTooltip(host, availability, t)} + {buildStatusTooltip(host, availability, t)} diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index bea22c96c..96a6b8031 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -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, diff --git a/src/ui/tests/api/host-status-api.test.ts b/src/ui/tests/api/host-status-api.test.ts index 0711894d7..9a33a6ac7 100644 --- a/src/ui/tests/api/host-status-api.test.ts +++ b/src/ui/tests/api/host-status-api.test.ts @@ -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", () => { diff --git a/src/ui/tests/hooks/use-status-color-scheme.test.ts b/src/ui/tests/hooks/use-status-color-scheme.test.ts index ef3a2cb2c..b74651356 100644 --- a/src/ui/tests/hooks/use-status-color-scheme.test.ts +++ b/src/ui/tests/hooks/use-status-color-scheme.test.ts @@ -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"); }); }); diff --git a/src/ui/tests/lib/server-status-store.test.ts b/src/ui/tests/lib/server-status-store.test.ts index d2c7ef1d0..a5e54ee56 100644 --- a/src/ui/tests/lib/server-status-store.test.ts +++ b/src/ui/tests/lib/server-status-store.test.ts @@ -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", () => { diff --git a/src/ui/tests/sidebar/live-host-status.test.ts b/src/ui/tests/sidebar/live-host-status.test.ts new file mode 100644 index 000000000..e192fa184 --- /dev/null +++ b/src/ui/tests/sidebar/live-host-status.test.ts @@ -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 = {}) => + ({ id: String(id), name: `h${id}`, online: false, ...extra }) as Host; + +const statuses: Record = { + 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); + }); +}); diff --git a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx index 2bbf3762c..5424ecc91 100644 --- a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx +++ b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx @@ -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", () => ({ diff --git a/src/ui/tests/sidebar/tree/HostItem/host-status-tooltip.test.ts b/src/ui/tests/sidebar/tree/HostItem/host-status-tooltip.test.ts index 2dd59b141..6e32cea13 100644 --- a/src/ui/tests/sidebar/tree/HostItem/host-status-tooltip.test.ts +++ b/src/ui/tests/sidebar/tree/HostItem/host-status-tooltip.test.ts @@ -27,24 +27,24 @@ function makeHost(overrides: Partial = {}): 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", () => {