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

+8 -1
View File
@@ -17,6 +17,8 @@ https://youtu.be/lngaePO96tM
- Added a reload button when a sign-in verification method fails to load - 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 - 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 - 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 - Completed the Simplified Chinese translation
- Documented cookie and API key sign-in in the API docs - 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 - Too many database writes from session activity
- Host editor tabs being hidden when they did not fit - 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 - 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 - 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 - 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 - Plugin audit log entries failing to save when no user was signed in
+12 -4
View File
@@ -840,19 +840,24 @@ export interface PluginHosts {
status: PluginHostStatusApi; 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 type PluginHostStatus = "online" | "reachable" | "offline";
export interface PluginHostStatusEntry { export interface PluginHostStatusEntry {
status: PluginHostStatus; status: PluginHostStatus;
lastChecked: string; lastChecked: string;
/** @deprecated No longer sent. */
reason?: "host_key_changed"; reason?: "host_key_changed";
} }
/** /**
* Core checks every host with status checks on by opening a TCP connection to * 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 * it. A plugin that logs in to hosts can report a working login, which marks
* turns "reachable" into "online". * the host online right away.
*/ */
export interface PluginHostStatusApi { export interface PluginHostStatusApi {
/** /**
@@ -862,7 +867,10 @@ export interface PluginHostStatusApi {
get: (hostId: number) => Promise<PluginHostStatusEntry | null>; get: (hostId: number) => Promise<PluginHostStatusEntry | null>;
/** Checks the host now unless core checked it recently. */ /** Checks the host now unless core checked it recently. */
check: (hostId: number) => Promise<PluginHostStatusEntry | null>; check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
/** 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: ( reportLogin: (
hostId: number, hostId: number,
outcome: { ok: boolean; hostKeyChanged?: boolean }, outcome: { ok: boolean; hostKeyChanged?: boolean },
+5 -3
View File
@@ -76,7 +76,7 @@ export interface PluginHostRecord {
quickConnectSavable?: boolean; quickConnectSavable?: boolean;
/** Assigned when a host is opened in a tab; tells duplicate tabs apart. */ /** Assigned when a host is opened in a tab; tells duplicate tabs apart. */
instanceId?: string; instanceId?: string;
/** Core's status: "online" once a login worked. */ /** Core's status: "online" when the host answers. */
status?: "online" | "reachable" | "offline" | "unknown"; status?: "online" | "reachable" | "offline" | "unknown";
online?: boolean; online?: boolean;
/** Someone else owns this host and shared it with the user. */ /** 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 * Core's status for a host, the one behind the host list's dot. online: the
* login worked. reachable: the port answers. unknown: not checked yet. * 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 { export interface HostStatusInfo {
status: "online" | "reachable" | "offline" | "unknown"; status: "online" | "reachable" | "offline" | "unknown";
/** @deprecated No longer sent. */
reason?: "host_key_changed"; reason?: "host_key_changed";
} }
@@ -89,7 +89,7 @@ describe("host maintenance", () => {
await service.tick(); await service.tick();
expect(server.mock.notifications).toHaveLength(0); expect(server.mock.notifications).toHaveLength(0);
server.mock.hostStatuses.set(1, { server.mock.hostStatuses.set(1, {
status: "reachable", status: "online",
lastChecked: "2026-01-01T13:21Z", lastChecked: "2026-01-01T13:21Z",
}); });
await service.tick(); await service.tick();
+1 -7
View File
@@ -341,13 +341,7 @@ export async function startConnect(
return; return;
} }
const { hostKeyChanged, authFailed } = classifyConnectError( const { authFailed } = classifyConnectError(message, logs);
message,
logs,
);
if (hostKeyChanged || authFailed) {
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
}
log.error("Docker SSH connection failed", { log.error("Docker SSH connection failed", {
hostId, hostId,
userId, userId,
+2 -6
View File
@@ -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(); const client = new FakeClient();
server = await startServer({ client }); server = await startServer({ client });
server.mock.ctx.ssh.connect = (async () => { server.mock.ctx.ssh.connect = (async () => {
@@ -311,10 +311,6 @@ describe("docker routes", () => {
}) as never; }) as never;
const response = await connect(server); const response = await connect(server);
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(server.mock.statusReports).toContainEqual({ expect(server.mock.statusReports).toEqual([]);
hostId: 7,
ok: false,
hostKeyChanged: false,
});
}); });
}); });
@@ -23,11 +23,9 @@ function StatusDot({ status }: { status: string }) {
const color = const color =
status === "online" status === "online"
? getAccentColor() ? getAccentColor()
: status === "reachable" : status === "offline"
? "#fbbf24" ? "#ef4444"
: status === "offline" : "#6b7280";
? "#ef4444"
: "#6b7280";
return ( return (
<span <span
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block" className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
@@ -52,23 +52,20 @@ function HostStatusWidget({
); );
} }
const online = statusInfo?.status === "online"; const status = statusInfo?.status;
const reachable = statusInfo?.status === "reachable"; const online = status === "online";
const known = online || status === "offline";
const onlineColor = !statusInfo const onlineColor = !known
? "#6b7280" ? "#6b7280"
: online : online
? getAccentColor() ? getAccentColor()
: reachable : "#ef4444";
? "#fbbf24" const onlineLabel = !known
: "#ef4444";
const onlineLabel = !statusInfo
? t("common.unknown") ? t("common.unknown")
: online : online
? t("common.online") ? t("common.online")
: reachable : t("common.offline");
? t("common.signInFailed")
: t("common.offline");
return ( return (
<div className="flex flex-col w-full h-full overflow-hidden"> <div className="flex flex-col w-full h-full overflow-hidden">
+1 -12
View File
@@ -347,11 +347,8 @@ export class MetricsPoller {
const settings = await this.settingsFor(host.id); const settings = await this.settingsFor(host.id);
if (!settings.metricsEnabled) return; if (!settings.metricsEnabled) return;
let authenticated = false;
try { try {
const metrics = await collectMetrics(this.deps, host, settings, () => { const metrics = await collectMetrics(this.deps, host, settings);
authenticated = true;
});
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() }); this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
this.ctx.hosts.status.reportLogin(host.id, { ok: true }); this.ctx.hosts.status.reportLogin(host.id, { ok: true });
this.setCollectionState(host, "collecting"); this.setCollectionState(host, "collecting");
@@ -367,14 +364,6 @@ export class MetricsPoller {
const hostKeyChanged = isHostKeyVerificationError(error); const hostKeyChanged = isHostKeyVerificationError(error);
const message = error instanceof Error ? error.message : ""; const message = error instanceof Error ? error.message : "";
const isAuthError = /authentication|permission denied/i.test(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( this.setCollectionState(
host, host,
hostKeyChanged hostKeyChanged
+2 -3
View File
@@ -1,8 +1,7 @@
import type { PluginHostStatus } from "@termix/plugin-sdk/backend";
import type { CpuSamples } from "./widgets/cpu-collector.js"; import type { CpuSamples } from "./widgets/cpu-collector.js";
import { createNetworkSamples } from "./widgets/network-collector.js"; import { createNetworkSamples } from "./widgets/network-collector.js";
export type HostStatus = "online" | "reachable" | "offline";
export class RequestQueue { export class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>(); private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>(); private processing = new Set<number>();
@@ -418,7 +417,7 @@ export class HostPollCache<THost extends { id: number } = { id: number }> {
* host answers. A host core does not check (status checks off) is tried. * host answers. A host core does not check (status checks off) is tried.
*/ */
export function canStartInitialMetrics( export function canStartInitialMetrics(
status: HostStatus | null | undefined, status: PluginHostStatus | null | undefined,
hasViewers: boolean, hasViewers: boolean,
): boolean { ): boolean {
return hasViewers && status !== "offline"; return hasViewers && status !== "offline";
@@ -40,7 +40,7 @@ function setup(hostIds = [7]) {
hostStatuses: Object.fromEntries( hostStatuses: Object.fromEntries(
hostIds.map((id) => [ hostIds.map((id) => [
id, id,
{ status: "reachable" as const, lastChecked: "now" }, { status: "online" as const, lastChecked: "now" },
]), ]),
), ),
}); });
@@ -131,13 +131,13 @@ describe("MetricsPoller", () => {
expect(collectMetrics).not.toHaveBeenCalled(); expect(collectMetrics).not.toHaveBeenCalled();
// Core's check comes back: the first sample runs. // Core's check comes back: the first sample runs.
fake.hostStatuses.set(7, { status: "reachable", lastChecked: "now" }); fake.hostStatuses.set(7, { status: "online", lastChecked: "now" });
poller.hostStatusChanged(7, "reachable"); poller.hostStatusChanged(7, "online");
await flush(); await flush();
expect(collectMetrics).toHaveBeenCalledOnce(); 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( collectMetrics.mockRejectedValue(
new Error("All configured authentication methods failed"), new Error("All configured authentication methods failed"),
); );
@@ -146,9 +146,7 @@ describe("MetricsPoller", () => {
poller.registerViewer(7, "viewer-a", "user-1"); poller.registerViewer(7, "viewer-a", "user-1");
await flush(); await flush();
expect(fake.statusReports).toEqual([ expect(fake.statusReports).toEqual([]);
{ hostId: 7, ok: false, hostKeyChanged: false },
]);
expect(fake.emitted.at(-1)?.payload).toMatchObject({ expect(fake.emitted.at(-1)?.payload).toMatchObject({
state: "auth_failed", state: "auth_failed",
}); });
@@ -174,9 +172,7 @@ describe("MetricsPoller", () => {
poller.registerViewer(7, "viewer-a", "user-1"); poller.registerViewer(7, "viewer-a", "user-1");
await flush(); await flush();
expect(fake.statusReports).toEqual([ expect(fake.statusReports).toEqual([]);
{ hostId: 7, ok: false, hostKeyChanged: true },
]);
expect(state.authFailures.shouldSkip(7)).toBe(true); expect(state.authFailures.shouldSkip(7)).toBe(true);
poller.hostKeyAccepted(7); poller.hostKeyAccepted(7);
expect(state.authFailures.shouldSkip(7)).toBe(false); expect(state.authFailures.shouldSkip(7)).toBe(false);
@@ -27,7 +27,6 @@ describe("auth failure tracking", () => {
describe("initial metrics admission", () => { describe("initial metrics admission", () => {
it("needs a viewer and a host core does not see as offline", () => { it("needs a viewer and a host core does not see as offline", () => {
expect(canStartInitialMetrics("online", true)).toBe(true); expect(canStartInitialMetrics("online", true)).toBe(true);
expect(canStartInitialMetrics("reachable", true)).toBe(true);
expect(canStartInitialMetrics("offline", true)).toBe(false); expect(canStartInitialMetrics("offline", true)).toBe(false);
expect(canStartInitialMetrics("online", false)).toBe(false); expect(canStartInitialMetrics("online", false)).toBe(false);
// Status checks off: core has no status, so the sample is tried. // Status checks off: core has no status, so the sample is tried.
@@ -73,7 +73,7 @@ const AVAILABLE_COLORS = [
{ value: "#6b7280", label: "Gray" }, { value: "#6b7280", label: "Gray" },
]; ];
type HostStatus = "online" | "reachable" | "offline" | "unknown"; type HostStatus = "online" | "offline" | "unknown";
interface HostWithStatus extends PluginHostRecord { interface HostWithStatus extends PluginHostRecord {
status?: HostStatus; status?: HostStatus;
@@ -184,8 +184,6 @@ function renderNodeSvg(
statusColor = useRealColors statusColor = useRealColors
? "rgb(16,185,129)" ? "rgb(16,185,129)"
: resolveCssVar("--accent-brand", "rgb(16,185,129)"); : resolveCssVar("--accent-brand", "rgb(16,185,129)");
} else if (status === "reachable") {
statusColor = "rgb(251,191,36)";
} else if (isOffline) { } else if (isOffline) {
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)"; statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
} else { } else {
@@ -46,7 +46,7 @@ export function registerHostStatusRoutes(
* /host/status: * /host/status:
* get: * get:
* summary: Get host statuses * 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: * tags:
* - SSH * - SSH
* parameters: * parameters:
+85 -89
View File
@@ -2,18 +2,16 @@
* The host list's status dot. * The host list's status dot.
* *
* Every host with status checks on gets a TCP connection to its port on a * 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". * timer. A port that answers is "online", one that does not (twice in a row)
* "reachable" is the warning in between: the port answers but the last login * is "offline". An open session or a working login also counts as online.
* failed (a session, a ctx.ssh connection or a plugin's * Probing never logs in to the host itself: on RADIUS or Duo backed devices a
* ctx.hosts.status.reportLogin said so). It clears on the next working login, * login fires a real 2FA push, every interval. A host behind jump hosts is
* when the host is edited, or when a changed host key is accepted. Probing * probed through a chain, which does log in to the hops, so hosts that share
* never logs in to the host itself: on RADIUS or Duo backed devices a login * a hop list share one chain (jump-probe-pool.ts).
* 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 * 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"; import { pluginEvents, TOPICS } from "../../plugins/events.js";
@@ -24,24 +22,21 @@ import {
getCurrentSettingValue, getCurrentSettingValue,
} from "../../database/repositories/factory.js"; } from "../../database/repositories/factory.js";
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js"; import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
import { import type { HostStatus } from "./host-status.js";
statusAfterAuthentication,
statusAfterReachabilityCheck,
type HostStatus,
} from "./host-status.js";
import { ConcurrentLimiter } from "./limiter.js"; import { ConcurrentLimiter } from "./limiter.js";
import { tcpPing } from "./tcp-ping.js"; import { tcpPing } from "./tcp-ping.js";
import { JumpProbePool } from "./jump-probe-pool.js"; import { JumpProbePool } from "./jump-probe-pool.js";
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval"; 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(). */ /** A status younger than this is fresh enough for check(). */
const FRESH_MS = 30_000; const FRESH_MS = 30_000;
export interface HostStatusEntry { export interface HostStatusEntry {
status: HostStatus; status: HostStatus;
lastChecked: string; lastChecked: string;
reason?: "host_key_changed";
} }
export interface HostStatusPayload { export interface HostStatusPayload {
@@ -51,7 +46,6 @@ export interface HostStatusPayload {
previous: HostStatus | null; previous: HostStatus | null;
/** Anything but offline, the shape automations' trigger already reads. */ /** Anything but offline, the shape automations' trigger already reads. */
online: boolean; online: boolean;
reason?: "host_key_changed";
} }
export interface StatusTarget { export interface StatusTarget {
@@ -84,6 +78,8 @@ export interface HostStatusDeps {
hasActiveSession?: (hostId: number) => boolean; hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number; globalInterval: () => number;
emit: (payload: HostStatusPayload) => void; emit: (payload: HostStatusPayload) => void;
/** Pause before a failed ping is tried again. */
retryDelayMs?: number;
} }
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> { function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
@@ -158,7 +154,6 @@ export class HostStatusService {
private readonly store = new Map<number, HostStatusEntry>(); private readonly store = new Map<number, HostStatusEntry>();
private readonly owners = new Map<number, string>(); private readonly owners = new Map<number, string>();
private readonly inFlight = new Map<number, Promise<void>>(); private readonly inFlight = new Map<number, Promise<void>>();
private readonly loginFailed = new Set<number>();
private readonly startedUsers = new Set<string>(); private readonly startedUsers = new Set<string>();
private readonly portResolvers = new Map<string, Set<PortResolver>>(); private readonly portResolvers = new Map<string, Set<PortResolver>>();
private readonly limiter = new ConcurrentLimiter(20); private readonly limiter = new ConcurrentLimiter(20);
@@ -175,19 +170,12 @@ export class HostStatusService {
}), }),
pluginEvents.on(TOPICS.hostUpdated, (payload) => { pluginEvents.on(TOPICS.hostUpdated, (payload) => {
const { hostId } = payload as { hostId?: number }; const { hostId } = payload as { hostId?: number };
if (!hostId) return; if (hostId) void this.reload(hostId);
// An edit may have fixed the login, so the next attempt decides.
this.loginFailed.delete(hostId);
void this.reload(hostId);
}), }),
pluginEvents.on(TOPICS.hostDeleted, (payload) => { pluginEvents.on(TOPICS.hostDeleted, (payload) => {
const { hostId } = payload as { hostId?: number }; const { hostId } = payload as { hostId?: number };
if (hostId) this.forget(hostId); 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, userId: string,
requestedHostIds: Set<number> | null, requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> { ): Promise<Map<number, HostStatusEntry>> {
const started: Promise<void>[] = [];
if (requestedHostIds !== null) { if (requestedHostIds !== null) {
await this.reconcile(userId, requestedHostIds); await this.reconcile(userId, requestedHostIds, started);
} else if (!this.startedUsers.has(userId)) { } 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; return this.store;
} }
@@ -249,20 +239,12 @@ export class HostStatusService {
return this.store.get(hostId) ?? null; return this.store.get(hostId) ?? null;
} }
reportLogin( /**
hostId: number, * A working login proves the host is up. A failed one says nothing about
outcome: { ok: boolean; hostKeyChanged?: boolean }, * reachability (a wrong password, another user's login), so it is ignored.
): void { */
if (outcome.ok) { reportLogin(hostId: number, outcome: { ok: boolean }): void {
this.loginFailed.delete(hostId); if (outcome.ok) this.set(hostId, { status: "online" });
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" } : {}),
});
} }
registerPort(connectionType: string, resolve: PortResolver): () => void { registerPort(connectionType: string, resolve: PortResolver): () => void {
@@ -278,13 +260,33 @@ export class HostStatusService {
}; };
} }
private async startUser(userId: string): Promise<void> { private async waitFor(started: Promise<void>[]): Promise<void> {
let timer: NodeJS.Timeout | undefined;
await Promise.race([
Promise.allSettled(started),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
timer.unref?.();
}),
]);
if (timer) clearTimeout(timer);
}
private track(started: Promise<void>[], target: StatusTarget): void {
const probe = this.startPolling(target);
if (probe) started.push(probe);
}
private async startUser(
userId: string,
started: Promise<void>[],
): Promise<void> {
this.startedUsers.add(userId); this.startedUsers.add(userId);
const targets = await this.deps.loadTargets({ userId }); const targets = await this.deps.loadTargets({ userId });
for (const target of targets) { 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( private async startShared(
userId: string, userId: string,
allowed: Set<number> | null, allowed: Set<number> | null,
started: Promise<void>[],
): Promise<void> { ): Promise<void> {
if (!this.deps.loadSharedHostIds) return; if (!this.deps.loadSharedHostIds) return;
try { try {
@@ -304,7 +307,7 @@ export class HostStatusService {
if (shared.length === 0) return; if (shared.length === 0) return;
const targets = await this.deps.loadTargets({ hostIds: shared }); const targets = await this.deps.loadTargets({ hostIds: shared });
for (const target of targets) { 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) { } catch (error) {
sshLogger.warn("Could not start status checks for shared hosts", { sshLogger.warn("Could not start status checks for shared hosts", {
@@ -315,7 +318,11 @@ export class HostStatusService {
} }
} }
private async reconcile(userId: string, allowed: Set<number>): Promise<void> { private async reconcile(
userId: string,
allowed: Set<number>,
started: Promise<void>[],
): Promise<void> {
const targets = await this.deps.loadTargets({ userId }); const targets = await this.deps.loadTargets({ userId });
for (const target of targets) { for (const target of targets) {
if (!allowed.has(target.id)) { if (!allowed.has(target.id)) {
@@ -323,9 +330,9 @@ export class HostStatusService {
this.store.delete(target.id); this.store.delete(target.id);
continue; 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<void> { private async reload(hostId: number): Promise<void> {
@@ -351,16 +358,6 @@ export class HostStatusService {
this.stopPolling(hostId); this.stopPolling(hostId);
this.store.delete(hostId); this.store.delete(hostId);
this.owners.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 { 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<void> | undefined {
this.stopPolling(target.id); this.stopPolling(target.id);
this.owners.set(target.id, target.userId); this.owners.set(target.id, target.userId);
if (!target.statusCheckEnabled) { if (!target.statusCheckEnabled) {
this.store.delete(target.id); this.store.delete(target.id);
return; return undefined;
} }
const polled: Polled = { target }; const polled: Polled = { target };
this.polled.set(target.id, polled); this.polled.set(target.id, polled);
if (probeNow) void this.probe(target); const first = probeNow ? this.probe(target) : undefined;
polled.timer = setInterval(() => { polled.timer = setInterval(() => {
const latest = this.polled.get(target.id); const latest = this.polled.get(target.id);
if (latest) void this.probe(latest.target); if (latest) void this.probe(latest.target);
}, this.intervalMs(target)); }, this.intervalMs(target));
polled.timer.unref?.(); polled.timer.unref?.();
return first;
} }
private stopPolling(hostId: number): void { private stopPolling(hostId: number): void {
@@ -427,33 +429,28 @@ export class HostStatusService {
return target.port; return target.port;
} }
private async pingOnce(target: StatusTarget): Promise<boolean> {
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<void> { private async probeNow(target: StatusTarget): Promise<void> {
this.owners.set(target.id, target.userId); this.owners.set(target.id, target.userId);
let reachable = false; let reachable = await this.pingOnce(target);
try { if (!reachable) {
if (this.deps.hasActiveSession?.(target.id)) { // One lost packet should not flip the dot.
reachable = true; const delay = this.deps.retryDelayMs ?? 1000;
} else { if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
const port = await this.portFor(target); reachable = await this.pingOnce(target);
reachable =
target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
}
} catch {
reachable = false;
} }
this.set(target.id, { status: reachable ? "online" : "offline" });
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 }
: {}),
});
} }
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) { private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
@@ -486,8 +483,7 @@ export class HostStatusService {
ownerUserId, ownerUserId,
status: entry.status, status: entry.status,
previous, previous,
online: entry.status !== "offline", online: entry.status === "online",
...(entry.reason ? { reason: entry.reason } : {}),
}); });
} }
} }
+1 -17
View File
@@ -1,4 +1,4 @@
export type HostStatus = "online" | "reachable" | "offline"; export type HostStatus = "online" | "offline";
export function isHostKeyVerificationError(error: unknown): boolean { export function isHostKeyVerificationError(error: unknown): boolean {
return ( return (
@@ -7,19 +7,3 @@ export function isHostKeyVerificationError(error: unknown): boolean {
error.message.includes("Host key changed")) 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";
}
+14 -2
View File
@@ -1,5 +1,5 @@
import type { Client } from "ssh2"; import type { Client } from "ssh2";
import { tcpPingThroughJumpHost } from "./tcp-ping.js"; import { tcpPingThroughJumpHostResult } from "./tcp-ping.js";
type OpenChain = ( type OpenChain = (
jumpHosts: Array<{ hostId: number }>, jumpHosts: Array<{ hostId: number }>,
@@ -44,7 +44,19 @@ export class JumpProbePool {
return false; return false;
} }
this.touch(key, entry, client); 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 { closeAll(): void {
+30 -6
View File
@@ -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<Client, "forwardOut" | "end">, jumpClient: Pick<Client, "forwardOut" | "end">,
host: string, host: string,
port: number, port: number,
timeoutMs = 5000, timeoutMs = 5000,
keepOpen = false, keepOpen = false,
): Promise<boolean> { ): Promise<JumpPingResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false; let settled = false;
const finish = (result: boolean) => { const finish = (result: JumpPingResult) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timeout); clearTimeout(timeout);
@@ -83,11 +88,30 @@ export function tcpPingThroughJumpHost(
resolve(result); 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) => { jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy(); 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<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
keepOpen = false,
): Promise<boolean> {
return (
(await tcpPingThroughJumpHostResult(
jumpClient,
host,
port,
timeoutMs,
keepOpen,
)) === "ok"
);
}
+3 -18
View File
@@ -33,7 +33,6 @@ import {
} from "../hosts/connect/auth-provider-registry.js"; } from "../hosts/connect/auth-provider-registry.js";
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js"; import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { isHostKeyVerificationError } from "../hosts/status/host-status.js";
import { import {
getLoginMethod, getLoginMethod,
registerLoginMethod, registerLoginMethod,
@@ -124,9 +123,6 @@ function withOverrides(
return merged as unknown as SshConnectHost; 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 { export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const pluginId = manifest.id; const pluginId = manifest.id;
const declared = manifest.capabilities; const declared = manifest.capabilities;
@@ -223,23 +219,13 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
return { ...(host as unknown as SshConnectHost), userId }; return { ...(host as unknown as SshConnectHost), userId };
}; };
/** /** A working login to a saved host core resolved marks it online. */
* A login to a saved host core resolved feeds its status dot. Only a clear const reportLogin = (host: number | PluginSshHost) => {
* auth or host key failure counts: a timeout says nothing about the login.
*/
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
const hostId = typeof host === "number" ? host : lookup(host)?.host.id; const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return; 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") void import("../hosts/status/host-status-service.js")
.then(({ hostStatusService }) => .then(({ hostStatusService }) =>
hostStatusService.reportLogin(hostId, outcome), hostStatusService.reportLogin(hostId, { ok: true }),
) )
.catch(() => {}); .catch(() => {});
}; };
@@ -299,7 +285,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
success: false, success: false,
errorMessage: error instanceof Error ? error.message : String(error), errorMessage: error instanceof Error ? error.message : String(error),
}); });
reportLogin(host, error);
throw error; throw error;
} }
}; };
@@ -88,7 +88,7 @@ beforeEach(() => {
state.statuses = new Map([ state.statuses = new Map([
[1, { status: "online", lastChecked: "t" }], [1, { status: "online", lastChecked: "t" }],
[2, { status: "offline", lastChecked: "t" }], [2, { status: "offline", lastChecked: "t" }],
[3, { status: "reachable", lastChecked: "t" }], [3, { status: "offline", lastChecked: "t" }],
]); ]);
service.statusesFor.mockImplementation(async () => state.statuses); service.statusesFor.mockImplementation(async () => state.statuses);
service.get.mockImplementation( service.get.mockImplementation(
@@ -55,6 +55,7 @@ function setup(
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id), hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60, globalInterval: () => 60,
emit: (payload) => emitted.push(payload), emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
}); });
return { service, emitted, ping, pingThroughJumpHosts, loadTargets }; return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
} }
@@ -192,86 +193,44 @@ describe("HostStatusService", () => {
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389); 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)]); const { service, emitted } = setup([target(1)]);
active = service; active = service;
await service.statusesFor("owner", null); await service.statusesFor("owner", null);
await flush();
const before = emitted.length;
service.reportLogin(1, { ok: false }); 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(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 () => { it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)]); const { service } = setup([target(1)], false);
active = service; active = service;
service.start(); service.start();
await service.statusesFor("owner", null); await service.statusesFor("owner", null);
await flush(); expect(service.get(1)?.status).toBe("offline");
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 });
const release = hostSessionStatus.register(1); const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online"); expect(service.get(1)?.status).toBe("online");
release(); 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"); expect(service.get(1)?.status).toBe("online");
}); });
it("shows an offline host as offline whatever the login said", async () => { it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1)], false); const { service } = setup([target(1), target(2)]);
active = service; active = service;
await service.statusesFor("owner", null); const statuses = await service.statusesFor("owner", null);
await flush(); expect(statuses.get(1)?.status).toBe("online");
service.reportLogin(1, { ok: false }); expect(statuses.get(2)?.status).toBe("online");
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");
}); });
it("only checks the requested hosts on the desktop app", async () => { it("only checks the requested hosts on the desktop app", async () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => { describe("isHostKeyVerificationError", () => {
it.each([ 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");
});
});
@@ -67,6 +67,29 @@ describe("JumpProbePool", () => {
pool.closeAll(); 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 () => { it("retries a chain that failed to open", async () => {
const open = vi const open = vi
.fn() .fn()
+5 -7
View File
@@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({
vi.mock("../../hosts/status/host-status-service.js", () => ({ vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: { hostStatusService: {
get: (hostId: number) => get: (hostId: number) =>
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null, hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({ check: async (hostId: number) => ({
status: "offline", status: "offline",
lastChecked: String(hostId), lastChecked: String(hostId),
@@ -496,7 +496,7 @@ describe("ctx.hosts", () => {
} }
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 }); expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
expect(record.pluginSettings).toEqual({ mine: "own-4" }); 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); expect(record.enableSsh).toBe(true);
}); });
@@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => {
audit: vi.fn(async () => {}), audit: vi.fn(async () => {}),
}); });
await expect(hosts.status.get(4)).resolves.toEqual({ await expect(hosts.status.get(4)).resolves.toEqual({
status: "reachable", status: "online",
lastChecked: "t", lastChecked: "t",
}); });
await expect(hosts.status.get(5)).resolves.toBeNull(); await expect(hosts.status.get(5)).resolves.toBeNull();
@@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => {
manifest: manifest(["hosts:read"]), manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}), audit: vi.fn(async () => {}),
}); });
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true }); hosts.status.reportLogin(4, { ok: true });
await settle(); await settle();
expect(h.statusReports).toEqual([ expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
{ hostId: 4, ok: false, hostKeyChanged: true },
]);
}); });
it("removes a registered port when the plugin is disposed", async () => { it("removes a registered port when the plugin is disposed", async () => {
@@ -220,24 +220,19 @@ describe("ctx.ssh", () => {
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]); 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"]); h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({ const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]), manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"), bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}), 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"); h.connectError = new Error("All configured authentication methods failed");
await expect(ssh.connect(7)).rejects.toThrow(); await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("Host key changed"); h.connectError = new Error("Host key changed");
await expect(ssh.connect(8)).rejects.toThrow(); await expect(ssh.connect(8)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins).toEqual([ expect(h.logins).toEqual([]);
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
]);
}); });
it("never believes a host object's own userId", async () => { it("never believes a host object's own userId", async () => {
+1 -1
View File
@@ -25,7 +25,7 @@ export type Host = {
*/ */
childHosts?: Host[]; childHosts?: Host[];
online: boolean; online: boolean;
status?: "online" | "reachable" | "offline" | "unknown"; status?: "online" | "offline" | "unknown";
cpu: number | null; cpu: number | null;
ram: number | null; ram: number | null;
lastAccess: string; lastAccess: string;
+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< export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus> Record<number, ServerStatus>
> { > {
@@ -71,31 +72,24 @@ export async function getAllServerStatuses(): Promise<
let remoteHostsBySyncId: Map<string, number> | null = null; let remoteHostsBySyncId: Map<string, number> | null = null;
if (isElectron()) { if (isElectron()) {
try { // A host-list failure throws here: sending no ids would stop every check.
const response = await sshHostApi.get<SSHHost[]>("/db/host"); const response = await sshHostApi.get<SSHHost[]>("/db/host");
const defaultOrigin = await resolveConnectionOrigin({ const defaultOrigin = await resolveConnectionOrigin({
connectionOrigin: null, connectionOrigin: null,
}); });
const hosts = response.data || []; const hosts = response.data || [];
localHostIds = hosts localHostIds = hosts
.filter((host) => (host.connectionOrigin ?? defaultOrigin) === "local")
.map((host) => host.id);
remoteHostsBySyncId = new Map(
hosts
.filter( .filter(
(host) => (host.connectionOrigin ?? defaultOrigin) === "local", (host) =>
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
!!host.syncId,
) )
.map((host) => host.id); .map((host) => [host.syncId as string, 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 = [];
}
} }
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
@@ -128,10 +122,7 @@ export async function getAllServerStatuses(): Promise<
} }
} }
if (lastError) { if (lastError) handleApiError(lastError, "fetch server statuses");
handleApiError(lastError, "fetch server statuses");
return {};
}
if (remoteHostsBySyncId?.size && (await getLinkedSession())) { if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
try { try {
+5 -31
View File
@@ -1,7 +1,7 @@
import { AxiosError } from "axios"; 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 { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
import { import {
getCachedSSHHosts, getCachedSSHHosts,
invalidateHostsAndStatusCaches, invalidateHostsAndStatusCaches,
@@ -10,42 +10,16 @@ import {
// SSH HOST MANAGEMENT // 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[]> { async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
const hostsResponse = await sshHostApi.get("/db/host"); const hostsResponse = await sshHostApi.get("/db/host");
return Array.isArray(hostsResponse.data) ? hostsResponse.data : []; return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
} }
export async function getSSHHosts( /** Host config only. Live status comes from ServerStatusContext. */
options: GetSSHHostsOptions = {}, export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
): Promise<SSHHostWithStatus[]> {
const includeStatus = options.includeStatus !== false;
try { try {
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi); const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
return hosts.map((host) => ({ ...host, status: "unknown" }));
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",
}));
} catch (error) { } catch (error) {
throw handleApiError(error, "fetch SSH hosts"); throw handleApiError(error, "fetch SSH hosts");
} }
+19 -15
View File
@@ -57,7 +57,11 @@ import {
useStatusColorScheme, useStatusColorScheme,
getStatusClasses, getStatusClasses,
} from "@/hooks/use-status-color-scheme"; } 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 { sshHostToHost } from "@/sidebar/HostManagerData";
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs"; import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
@@ -429,7 +433,7 @@ export function HostStatusCard({
</span> </span>
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{online}/{hosts.length} {t("hosts.status.available")} {online}/{hosts.length} {t("hosts.status.online")}
</span> </span>
</div> </div>
<div className="flex flex-col overflow-auto flex-1"> <div className="flex flex-col overflow-auto flex-1">
@@ -439,12 +443,7 @@ export function HostStatusCard({
</div> </div>
)} )}
{hosts.map((host, i) => { {hosts.map((host, i) => {
const availability = const availability = host.status ?? "offline";
host.status && host.status !== "unknown"
? host.status
: host.online
? "online"
: "offline";
return ( return (
<div <div
key={i} key={i}
@@ -492,13 +491,11 @@ export function HostStatusCard({
<span <span
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`} className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
> >
{statusLoading {statusLoading || availability === "unknown"
? t("dashboardTab.checking") ? t("dashboardTab.checking")
: availability === "online" : availability === "online"
? t("hosts.status.available") ? t("hosts.status.online")
: availability === "reachable" : t("hosts.status.offline")}
? t("hosts.status.reachable")
: t("hosts.status.offline")}
</span> </span>
</div> </div>
</div> </div>
@@ -582,7 +579,7 @@ function RecentActivityCard({
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<span <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"> <div className="flex flex-col min-w-0 flex-1">
<span className="text-xs font-semibold truncate"> <span className="text-xs font-semibold truncate">
@@ -1102,7 +1099,14 @@ export function DashboardTab({
} }
}, [mainWidthPct]); }, [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 [isAdmin, setIsAdmin] = useState(false);
const [uptimeFormatted, setUptimeFormatted] = useState(""); const [uptimeFormatted, setUptimeFormatted] = useState("");
const [versionText, setVersionText] = 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. */ /** Returns Tailwind class names for a status dot/stripe. */
export function getStatusClasses( export function getStatusClasses(
status: boolean | "online" | "reachable" | "offline" | "degraded", status: boolean | "online" | "offline" | "unknown",
scheme: StatusColorScheme, scheme: StatusColorScheme,
variant: "dot" | "stripe" | "badge", variant: "dot" | "stripe" | "badge",
loading = false, loading = false,
): string { ): string {
const online = status === true || status === "online"; const online = status === true || status === "online";
const reachable = status === "reachable"; if (loading || status === "unknown") {
if (loading) {
if (scheme === "status") { if (scheme === "status") {
if (variant === "dot") return "bg-yellow-400 animate-pulse"; if (variant === "dot") return "bg-yellow-400 animate-pulse";
if (variant === "stripe") return "bg-yellow-400/40 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"; if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
return "border-border/50 text-muted-foreground/50 bg-muted/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 (scheme === "status") {
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500"; if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
if (variant === "stripe") if (variant === "stripe")
+85 -189
View File
@@ -9,7 +9,8 @@ import React, {
useSyncExternalStore, useSyncExternalStore,
useState, useState,
} from "react"; } from "react";
import { getAllServerStatuses, getSSHHosts } from "@/main-axios"; import { getAllServerStatuses } from "@/main-axios";
import { invalidateServerStatusCache } from "./hosts-request-cache";
import { import {
ServerStatusStore, ServerStatusStore,
type ServerStatusEntry, type ServerStatusEntry,
@@ -19,17 +20,26 @@ import { runAdaptivePolling } from "./adaptive-polling";
interface ServerStatusContextType { interface ServerStatusContextType {
statuses: Map<number, ServerStatusEntry>; statuses: Map<number, ServerStatusEntry>;
isLoading: boolean;
initialLoadComplete: boolean; initialLoadComplete: boolean;
refreshStatuses: () => Promise<void>; refreshStatuses: () => Promise<void>;
/** "unknown" until the host has been checked. */
getStatus: (hostId: number) => StatusValue; 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 StatusStoreContext = createContext<ServerStatusStore | null>(null);
const ServerStatusContext = createContext<ServerStatusContextType | 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({ export function ServerStatusProvider({
children, children,
@@ -47,121 +57,48 @@ export function ServerStatusProvider({
// Bumps only full-context consumers (dashboard, folder counts, etc.). // Bumps only full-context consumers (dashboard, folder counts, etc.).
const [version, setVersion] = useState(0); const [version, setVersion] = useState(0);
const mountedRef = useRef(true); 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(() => { useEffect(() => {
return store.subscribeAll(() => { activeStore = store;
setVersion((v) => v + 1); return () => {
}); if (activeStore === store) activeStore = null;
};
}, [store]); }, [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 () => { const refreshStatuses = useCallback(async () => {
await refreshStatusesImpl(); if (!mountedRef.current || !isAuthenticated) return;
}, [refreshStatusesImpl]); 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( const getStatus = useCallback(
(hostId: number): StatusValue => store.getStatus(hostId), (hostId: number): StatusValue => store.getStatus(hostId),
@@ -170,35 +107,31 @@ export function ServerStatusProvider({
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
if (!isAuthenticated) {
store.clear();
return () => {
mountedRef.current = false;
};
}
let stopPolling: (() => void) | null = null; const stopPolling = runAdaptivePolling(refreshStatuses, {
minIntervalMs: POLL_INTERVAL,
const init = async () => { maxIntervalMs: POLL_INTERVAL,
await fetchEnabledHosts(); });
if (!mountedRef.current) return; const onFocus = () => void refreshStatuses();
stopPolling = runAdaptivePolling( window.addEventListener("focus", onFocus);
() => refreshStatusesImpl(true),
{
minIntervalMs: POLL_INTERVAL,
maxIntervalMs: 120_000,
stablePollsPerStep: 3,
},
{ enabled: () => isAuthenticated },
);
};
void init();
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
stopPolling?.(); stopPolling();
window.removeEventListener("focus", onFocus);
}; };
}, [fetchEnabledHosts, isAuthenticated, refreshStatusesImpl]); }, [isAuthenticated, refreshStatuses, store]);
useEffect(() => { useEffect(() => {
const handleHostsChanged = async () => { const handleHostsChanged = () => {
await fetchEnabledHosts(); invalidateServerStatusCache();
await refreshStatuses(); void refreshStatuses();
}; };
window.addEventListener("ssh-hosts:changed", handleHostsChanged); window.addEventListener("ssh-hosts:changed", handleHostsChanged);
@@ -208,17 +141,16 @@ export function ServerStatusProvider({
window.removeEventListener("ssh-hosts:changed", handleHostsChanged); window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
window.removeEventListener("hosts:refresh", handleHostsChanged); window.removeEventListener("hosts:refresh", handleHostsChanged);
}; };
}, [fetchEnabledHosts, refreshStatuses]); }, [refreshStatuses]);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
statuses: store.getStatuses(), statuses: store.getStatuses(),
isLoading: store.getIsLoading(),
initialLoadComplete: store.getInitialLoadComplete(), initialLoadComplete: store.getInitialLoadComplete(),
refreshStatuses, refreshStatuses,
getStatus, getStatus,
}), }),
// version refreshes statuses/isLoading/initialLoadComplete snapshots // version refreshes the statuses/initialLoadComplete snapshots
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[version, refreshStatuses, getStatus], [version, refreshStatuses, getStatus],
); );
@@ -252,85 +184,49 @@ export function useServerStatus() {
/** /**
* Subscribe to a single host's status. Only re-renders when that host's * 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 * status changes. Null when status checks are off for the host.
* other hosts update.
*/ */
export function useHostStatus( export function useHostStatus(
hostId: number, hostId: number,
statusCheckEnabled: boolean = true, statusCheckEnabled: boolean = true,
): StatusValue | null { ): StatusValue | null {
const store = useStatusStore(); const store = useStatusStore();
const status = useSyncExternalStore(
const snapshot = useSyncExternalStore(
(onChange) => store.subscribeHost(hostId, onChange), (onChange) => store.subscribeHost(hostId, onChange),
() => store.getHostSnapshot(hostId), () => store.getStatus(hostId),
() => store.getHostSnapshot(hostId), () => store.getStatus(hostId),
); );
return statusCheckEnabled ? status : null;
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;
} }
/** Meta flags without depending on the full status map. */ /** Meta flags without depending on the full status map. */
export function useServerStatusMeta(): { export function useServerStatusMeta(): { initialLoadComplete: boolean } {
initialLoadComplete: boolean;
isLoading: boolean;
} {
const store = useStatusStore(); const store = useStatusStore();
useSyncExternalStore( useSyncExternalStore(
(onChange) => store.subscribeMeta(onChange), (onChange) => store.subscribeMeta(onChange),
() => store.getMetaSnapshot(), () => store.getMetaSnapshot(),
() => store.getMetaSnapshot(), () => store.getMetaSnapshot(),
); );
return { initialLoadComplete: store.getInitialLoadComplete() };
return {
initialLoadComplete: store.getInitialLoadComplete(),
isLoading: store.getIsLoading(),
};
} }
const noStatus = () => ""; const noStatus = (): StatusValue => "unknown";
/** /**
* One host's status for plugin views, which can render outside the app shell * 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. * (a standalone window). Null there, and while the host has not been checked.
*/ */
export function useOptionalHostStatusEntry(hostId: number | undefined): { export function useOptionalHostStatusEntry(
status: StatusValue; hostId: number | undefined,
reason?: "host_key_changed"; ): { status: "online" | "offline" } | null {
} | null {
const store = useContext(StatusStoreContext); const store = useContext(StatusStoreContext);
const snapshot = useSyncExternalStore( const status = useSyncExternalStore(
(onChange) => (onChange) =>
store && hostId !== undefined store && hostId !== undefined
? store.subscribeHost(hostId, onChange) ? store.subscribeHost(hostId, onChange)
: () => {}, : () => {},
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""), () => (store && hostId !== undefined ? store.getStatus(hostId) : "unknown"),
noStatus, noStatus,
); );
if (!snapshot) return null; return status === "unknown" ? null : { status };
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 };
} }
+12 -87
View File
@@ -1,9 +1,8 @@
export type StatusValue = "online" | "reachable" | "offline" | "degraded"; export type StatusValue = "online" | "offline" | "unknown";
export interface ServerStatusEntry { export interface ServerStatusEntry {
status: StatusValue; status: "online" | "offline";
lastChecked: string; lastChecked: string;
reason?: "host_key_changed";
} }
type Listener = () => void; type Listener = () => void;
@@ -14,44 +13,26 @@ type Listener = () => void;
*/ */
export class ServerStatusStore { export class ServerStatusStore {
private statuses = new Map<number, ServerStatusEntry>(); private statuses = new Map<number, ServerStatusEntry>();
private enabledHostIds = new Set<number>();
private initialLoadComplete = false; private initialLoadComplete = false;
private isLoading = false;
private readonly hostListeners = new Map<number, Set<Listener>>(); private readonly hostListeners = new Map<number, Set<Listener>>();
private readonly allListeners = new Set<Listener>(); private readonly allListeners = new Set<Listener>();
private readonly metaListeners = new Set<Listener>(); private readonly metaListeners = new Set<Listener>();
/** "unknown" until the host has been checked. */
getStatus(hostId: number): StatusValue { getStatus(hostId: number): StatusValue {
if (!this.enabledHostIds.has(hostId)) { return this.statuses.get(hostId)?.status ?? "unknown";
return "offline";
}
return this.statuses.get(hostId)?.status || "degraded";
} }
getStatuses(): Map<number, ServerStatusEntry> { getStatuses(): Map<number, ServerStatusEntry> {
return this.statuses; return this.statuses;
} }
getEnabledHostIds(): Set<number> {
return this.enabledHostIds;
}
getInitialLoadComplete(): boolean { getInitialLoadComplete(): boolean {
return this.initialLoadComplete; 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 { 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 { 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 { setInitialLoadComplete(complete: boolean): void {
if (this.initialLoadComplete === complete) return; if (this.initialLoadComplete === complete) return;
this.initialLoadComplete = complete; this.initialLoadComplete = complete;
@@ -120,50 +79,24 @@ export class ServerStatusStore {
const changedIds: number[] = []; const changedIds: number[] = [];
for (const [id, entry] of next) { for (const [id, entry] of next) {
const prev = this.statuses.get(id); if (this.statuses.get(id)?.status !== entry.status) changedIds.push(id);
if (
!prev ||
prev.status !== entry.status ||
prev.reason !== entry.reason
) {
changedIds.push(id);
}
} }
for (const id of this.statuses.keys()) { for (const id of this.statuses.keys()) {
if (!next.has(id)) { if (!next.has(id)) changedIds.push(id);
changedIds.push(id);
}
}
if (changedIds.length === 0) {
// Still refresh lastChecked silently without notifying.
this.statuses = next;
return;
} }
this.statuses = next; this.statuses = next;
if (changedIds.length === 0) return;
for (const id of changedIds) { for (const id of changedIds) {
this.emitHost(id); this.emitHost(id);
} }
this.emitAll(); this.emitAll();
} }
markDegraded(enabledIds: Iterable<number>): void { /** Forgets everything, on sign out. */
const next = new Map(this.statuses); clear(): void {
let changed = false; this.applyStatuses(new Map());
const now = new Date().toISOString(); this.setInitialLoadComplete(false);
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);
}
} }
private emitHost(hostId: number): void { private emitHost(hostId: number): void {
@@ -180,11 +113,3 @@ export class ServerStatusStore {
for (const listener of this.metaListeners) listener(); 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", "minimize": "Minimize",
"online": "Online", "online": "Online",
"offline": "Offline", "offline": "Offline",
"signInFailed": "Sign in failed",
"unknown": "Unknown", "unknown": "Unknown",
"continue": "Continue", "continue": "Continue",
"maintenance": "Maintenance", "maintenance": "Maintenance",
@@ -358,10 +357,10 @@
"tunnel": "Tunnel", "tunnel": "Tunnel",
"serverStats": "Host Metrics", "serverStats": "Host Metrics",
"status": { "status": {
"available": "Available", "online": "Online",
"reachable": "Available, but login failed",
"offline": "Offline", "offline": "Offline",
"monitoringDisabled": "Monitoring disabled", "checking": "Checking",
"monitoringDisabled": "Status checks off",
"label": "Status" "label": "Status"
}, },
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "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"; import { getDeviceId } from "@/lib/device-id";
export type ServerStatus = { export type ServerStatus = {
status: "online" | "reachable" | "offline"; status: "online" | "offline";
lastChecked: string; lastChecked: string;
reason?: "host_key_changed";
}; };
export type SSHHostWithStatus = SSHHost & { export type SSHHostWithStatus = SSHHost & {
status: "online" | "reachable" | "offline" | "unknown"; status: "online" | "offline" | "unknown";
}; };
export interface AuthResponse { export interface AuthResponse {
+12 -10
View File
@@ -46,7 +46,10 @@ import { invokeAction } from "@/shell/action-registry";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useActionSlot } from "@/hooks/use-action-slot"; import { useActionSlot } from "@/hooks/use-action-slot";
import { usePluginComponent } from "./component-registry"; 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 * Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
@@ -370,12 +373,7 @@ export const pluginHostBridge: PluginHostBridge = {
usePluginComponent: (id) => usePluginComponent(id), usePluginComponent: (id) => usePluginComponent(id),
useHostStatus: (hostId) => { useHostStatus: (hostId) => {
const entry = useOptionalHostStatusEntry(hostId); return useOptionalHostStatusEntry(hostId);
if (!entry) return null;
return {
status: entry.status === "degraded" ? "unknown" : entry.status,
...(entry.reason ? { reason: entry.reason } : {}),
};
}, },
useHostActions: () => useHostActions() as unknown as HostActionContribution[], useHostActions: () => useHostActions() as unknown as HostActionContribution[],
@@ -429,9 +427,13 @@ export const pluginHostBridge: PluginHostBridge = {
void setCookie(name, value, 365); void setCookie(name, value, 365);
}, },
listHosts: async (pluginId) => listHosts: async (pluginId) =>
(await getSSHHosts({ includeStatus: false })).map((host) => (await getSSHHosts()).map((host) => {
toPluginHostRecord(host, pluginId ?? null), const status = getLiveHostStatus(host.id);
), return toPluginHostRecord(
{ ...host, status, online: status === "online" },
pluginId ?? null,
);
}),
notifyHostsChanged: () => { notifyHostsChanged: () => {
window.dispatchEvent(new CustomEvent("termix:hosts-changed")); window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
}, },
+4 -2
View File
@@ -44,6 +44,7 @@ import {
} from "./palette-registry"; } from "./palette-registry";
import { activityTarget } from "@/lib/activity-types"; import { activityTarget } from "@/lib/activity-types";
import { shell } from "@/plugin-host/shell-bridge"; import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
interface CommandPaletteProps { interface CommandPaletteProps {
isOpen: boolean; isOpen: boolean;
@@ -501,7 +502,7 @@ export function CommandPalette({
<Server <Server
className={cn( className={cn(
"size-4", "size-4",
host.online getLiveHostStatus(Number(host.id)) === "online"
? "text-accent-brand" ? "text-accent-brand"
: "text-muted-foreground", : "text-muted-foreground",
)} )}
@@ -517,7 +518,8 @@ export function CommandPalette({
{t("hosts.sharing.sharedBadge")} {t("hosts.sharing.sharedBadge")}
</span> </span>
)} )}
{host.online && ( {getLiveHostStatus(Number(host.id)) ===
"online" && (
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" /> <span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
)} )}
</div> </div>
+1 -1
View File
@@ -117,7 +117,7 @@ export function AdminSettingsPanel({
const [passwordLoginForced, setPasswordLoginForced] = useState(false); const [passwordLoginForced, setPasswordLoginForced] = useState(false);
const [allowPasswordReset, setAllowPasswordReset] = useState(true); const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24"); const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("60"); const [statusInterval, setStatusInterval] = useState("30");
const [logLevel, setLogLevel] = useState("info"); const [logLevel, setLogLevel] = useState("info");
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] = const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]); useState<string[]>([]);
+13 -6
View File
@@ -1,7 +1,7 @@
import { getErrorMessage } from "../lib/error-message.js"; import { getErrorMessage } from "../lib/error-message.js";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols"; import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; 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 type { HostData } from "@/types/index";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -53,6 +53,8 @@ import {
import type { SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, HostFolder, TabType } from "@/types/ui-types"; import type { Host, HostFolder, TabType } from "@/types/ui-types";
import { sortHostTree, type SortKey } from "@/sidebar/host-sort"; 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 { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock"; import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import type { import type {
@@ -137,9 +139,7 @@ function hostPassesFilters(
if (filters.status.length > 0) { if (filters.status.length > 0) {
const ok = const ok =
(filters.status.includes("online") && host.online) || (filters.status.includes("online") && host.online) ||
(filters.status.includes("offline") && (filters.status.includes("offline") && host.status === "offline") ||
host.status !== "reachable" &&
!host.online) ||
(filters.status.includes("pinned") && !!host.pin); (filters.status.includes("pinned") && !!host.pin);
if (!ok) return false; if (!ok) return false;
} }
@@ -219,6 +219,13 @@ export function HostsPanel({
active?: boolean; active?: boolean;
}) { }) {
const { t } = useTranslation(); 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( const hostSwitchPlugins = usePluginHostSections().filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey, (plugin) => !!plugin.contributes?.settings?.host?.enableKey,
); );
@@ -1028,10 +1035,10 @@ export function HostsPanel({
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<SidebarTree <SidebarTree
children={ children={
hostTree liveHostTree
? groupHosts( ? groupHosts(
applyFilters( applyFilters(
sortHostTree(hostTree, sortKey, pinnedFirst), sortHostTree(liveHostTree, sortKey, pinnedFirst),
filterState, filterState,
hostSwitches, 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 { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
import { FolderActions } from "./FolderActions"; import { FolderActions } from "./FolderActions";
export function folderHostCount(folder: HostFolder): { export function folderHostCount(folder: HostFolder): number {
total: number; let total = 0;
online: number;
} {
let total = 0,
online = 0;
for (const child of folder.children) { for (const child of folder.children) {
if (isFolder(child)) { total += isFolder(child) ? folderHostCount(child) : 1;
const c = folderHostCount(child);
total += c.total;
online += c.online;
} else {
total++;
if (child.online) online++;
}
} }
return { total, online }; return total;
} }
export function FolderItem({ export function FolderItem({
@@ -140,13 +129,11 @@ export function FolderItem({
onReorderHoverChange?: (edge: "before" | "after" | null) => void; onReorderHoverChange?: (edge: "before" | "after" | null) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getStatus, initialLoadComplete } = useServerStatus(); const { getStatus } = useServerStatus();
const { total } = folderHostCount(folder); const total = folderHostCount(folder);
const online = initialLoadComplete const online = collectAllHosts(folder.children).filter(
? collectAllHosts(folder.children).filter( (h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online", ).length;
).length
: folderHostCount(folder).online;
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const reorderEdge = isReorderHovered ? reorderHoverEdge : null; const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
+11 -28
View File
@@ -59,11 +59,7 @@ import {
useStatusColorScheme, useStatusColorScheme,
getStatusClasses, getStatusClasses,
} from "@/hooks/use-status-color-scheme"; } from "@/hooks/use-status-color-scheme";
import { import { useHostStatus } from "@/lib/ServerStatusContext";
useHostStatus,
useHostStatusReason,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -101,16 +97,16 @@ export function statusCheckEnabled(host: Host): boolean {
export function buildStatusTooltip( export function buildStatusTooltip(
host: Host, host: Host,
status: "online" | "reachable" | "offline", status: "online" | "offline" | "unknown",
t: (key: string) => string = (k) => k, t: (key: string) => string = (k) => k,
): string { ): string {
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
const statusLabel = const statusLabel =
status === "online" status === "online"
? t("hosts.status.available") ? t("hosts.status.online")
: status === "reachable" : status === "offline"
? t("hosts.status.reachable") ? t("hosts.status.offline")
: t("hosts.status.offline"); : t("hosts.status.checking");
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
const protocols: string[] = []; const protocols: string[] = [];
if (host.enableSsh) protocols.push("SSH"); if (host.enableSsh) protocols.push("SSH");
for (const protocol of enabledHostProtocols(host)) { for (const protocol of enabledHostProtocols(host)) {
@@ -283,22 +279,11 @@ export function HostItem({
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host); const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const splitTargets = useSplitTargets(); const splitTargets = useSplitTargets();
const statusScheme = useStatusColorScheme(); const statusScheme = useStatusColorScheme();
const { initialLoadComplete } = useServerStatusMeta();
const statusCheckOn = statusCheckEnabled(host); const statusCheckOn = statusCheckEnabled(host);
const statusLoading = !initialLoadComplete && statusCheckOn; // Per-host subscription, so a poll only re-renders rows that flipped.
// 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);
const availability = const availability =
liveStatus === "online" || useHostStatus(Number(host.id), statusCheckOn) ?? "offline";
liveStatus === "reachable" || const statusLoading = availability === "unknown";
liveStatus === "offline"
? liveStatus
: host.status === "reachable"
? "reachable"
: host.online
? "online"
: "offline";
const isOnline = availability === "online"; const isOnline = availability === "online";
const previousAvailability = useRef(availability); const previousAvailability = useRef(availability);
const [statusLocking, setStatusLocking] = useState(false); const [statusLocking, setStatusLocking] = useState(false);
@@ -1136,9 +1121,7 @@ export function HostItem({
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
{statusReason === "host_key_changed" {buildStatusTooltip(host, availability, t)}
? `${t("hostKey.keyChangedWarning")}: ${t("hostKey.keyChangedDescription")}`
: buildStatusTooltip(host, availability, t)}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
+1 -1
View File
@@ -502,7 +502,7 @@ export function SidebarTree({
function handleDeleteFolder(folder: HostFolder) { function handleDeleteFolder(folder: HostFolder) {
const folderPath = folder.path ?? folder.name; const folderPath = folder.path ?? folder.name;
const { total } = folderHostCount(folder); const total = folderHostCount(folder);
setConfirmDialog({ setConfirmDialog({
message: t("hosts.deleteFolderConfirm", { message: t("hosts.deleteFolderConfirm", {
name: folder.name, name: folder.name,
+15 -2
View File
@@ -49,7 +49,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// The server keys statuses by sync id; its own ids mean nothing here. // The server keys statuses by sync id; its own ids mean nothing here.
remoteCoreApiMock.get.mockResolvedValue({ 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({ await expect(getAllServerStatuses()).resolves.toEqual({
1: { status: "online" }, 1: { status: "online" },
2: { status: "reachable" }, 2: { status: "offline" },
}); });
expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", { expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", {
@@ -91,6 +91,19 @@ describe("status check origin routing", () => {
__silentRetry: true, __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", () => { describe("status check routes", () => {
@@ -66,8 +66,8 @@ describe("getStatusClasses", () => {
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand"); expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand");
}); });
it("draws reachable as a warning in both schemes", () => { it("draws an unchecked host as loading", () => {
expect(getStatusClasses("reachable", "accent", "dot")).toContain("amber"); expect(getStatusClasses("unknown", "accent", "dot")).toContain("pulse");
expect(getStatusClasses("reachable", "status", "dot")).toContain("amber"); expect(getStatusClasses("unknown", "status", "dot")).toContain("pulse");
}); });
}); });
+11 -37
View File
@@ -4,7 +4,6 @@ import { ServerStatusStore } from "../../lib/server-status-store";
describe("ServerStatusStore", () => { describe("ServerStatusStore", () => {
it("notifies only listeners for hosts whose status value changed", () => { it("notifies only listeners for hosts whose status value changed", () => {
const store = new ServerStatusStore(); const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1, 2, 3]));
const on1 = vi.fn(); const on1 = vi.fn();
const on2 = vi.fn(); const on2 = vi.fn();
@@ -43,7 +42,6 @@ describe("ServerStatusStore", () => {
it("does not notify when the status map is unchanged", () => { it("does not notify when the status map is unchanged", () => {
const store = new ServerStatusStore(); const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1]));
store.applyStatuses( store.applyStatuses(
new Map([[1, { status: "online", lastChecked: "t1" }]]), new Map([[1, { status: "online", lastChecked: "t1" }]]),
); );
@@ -57,47 +55,23 @@ describe("ServerStatusStore", () => {
expect(store.getStatus(1)).toBe("online"); 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(); const store = new ServerStatusStore();
store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]])); store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]]));
expect(store.getStatus(5)).toBe("offline"); store.setInitialLoadComplete(true);
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" }]]),
);
const listener = vi.fn(); const listener = vi.fn();
store.subscribeHost(7, listener); store.subscribeHost(5, listener);
store.applyStatuses( store.clear();
new Map([
[
7,
{
status: "reachable",
reason: "host_key_changed",
lastChecked: "t2",
},
],
]),
);
expect(store.getStatus(5)).toBe("unknown");
expect(store.getInitialLoadComplete()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1); expect(listener).toHaveBeenCalledTimes(1);
expect(store.getHostSnapshot(7)).toBe("reachable:host_key_changed");
}); });
it("emits meta listeners when initial load completes", () => { 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", () => ({ vi.mock("@/lib/ServerStatusContext", () => ({
useHostStatus: () => null, useHostStatus: () => null,
useHostStatusReason: () => null,
useServerStatus: () => ({ useServerStatus: () => ({
getStatus: () => "online", getStatus: () => "online",
initialLoadComplete: true, initialLoadComplete: true,
}), }),
useServerStatusMeta: () => ({ initialLoadComplete: true, isLoading: false }), useServerStatusMeta: () => ({ initialLoadComplete: true }),
})); }));
vi.mock("@/hooks/use-status-color-scheme", () => ({ 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. // not the key paths the translator falls back to when a resource is missing.
const t = (key: string): string => const t = (key: string): string =>
({ ({
"hosts.status.available": "Available", "hosts.status.online": "Online",
"hosts.status.reachable": "Reachable, not authenticated", "hosts.status.checking": "Checking",
"hosts.status.offline": "Offline", "hosts.status.offline": "Offline",
"hosts.status.monitoringDisabled": "Monitoring disabled", "hosts.status.monitoringDisabled": "Monitoring disabled",
})[key] ?? key; })[key] ?? key;
describe("buildStatusTooltip", () => { describe("buildStatusTooltip", () => {
it("returns the translated 'Available' label for online status", () => { it("returns the translated 'Online' label for online status", () => {
const host = makeHost(); const host = makeHost();
const tooltip = buildStatusTooltip(host, "online", t); const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toContain("Available"); expect(tooltip).toContain("Online");
expect(tooltip).not.toContain("hosts.status."); 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 host = makeHost();
const tooltip = buildStatusTooltip(host, "reachable", t); const tooltip = buildStatusTooltip(host, "unknown", t);
expect(tooltip).toContain("Reachable, not authenticated"); expect(tooltip).toContain("Checking");
expect(tooltip).not.toContain("hosts.status."); expect(tooltip).not.toContain("hosts.status.");
}); });
@@ -78,13 +78,13 @@ describe("buildStatusTooltip", () => {
dispose(); dispose();
expect(tooltip).toContain("SSH"); expect(tooltip).toContain("SSH");
expect(tooltip).toContain("Demo Desktop"); expect(tooltip).toContain("Demo Desktop");
expect(tooltip).toContain("Available"); expect(tooltip).toContain("Online");
}); });
it("returns just the status label when no protocols are enabled", () => { it("returns just the status label when no protocols are enabled", () => {
const host = makeHost({ enableSsh: false }); const host = makeHost({ enableSsh: false });
const tooltip = buildStatusTooltip(host, "online", t); 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", () => { it("does not render key paths when a translator is supplied", () => {