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

+15 -2
View File
@@ -49,7 +49,7 @@ beforeEach(() => {
vi.clearAllMocks();
// The server keys statuses by sync id; its own ids mean nothing here.
remoteCoreApiMock.get.mockResolvedValue({
data: { "sync-2": { status: "reachable" }, "sync-9": { status: "x" } },
data: { "sync-2": { status: "offline" }, "sync-9": { status: "x" } },
});
});
@@ -65,7 +65,7 @@ describe("status check origin routing", () => {
await expect(getAllServerStatuses()).resolves.toEqual({
1: { status: "online" },
2: { status: "reachable" },
2: { status: "offline" },
});
expect(sshHostApiMock.get).toHaveBeenCalledWith("/status", {
@@ -91,6 +91,19 @@ describe("status check origin routing", () => {
__silentRetry: true,
});
});
it("skips the tick when the host list cannot be read", async () => {
sshHostApiMock.get.mockImplementation(async (path: string) => {
if (path === "/db/host") throw new Error("down");
return { data: {} };
});
await expect(getAllServerStatuses()).rejects.toThrow("down");
expect(sshHostApiMock.get).not.toHaveBeenCalledWith(
"/status",
expect.anything(),
);
});
});
describe("status check routes", () => {
@@ -66,8 +66,8 @@ describe("getStatusClasses", () => {
expect(getStatusClasses(true, "accent", "dot")).toContain("accent-brand");
});
it("draws reachable as a warning in both schemes", () => {
expect(getStatusClasses("reachable", "accent", "dot")).toContain("amber");
expect(getStatusClasses("reachable", "status", "dot")).toContain("amber");
it("draws an unchecked host as loading", () => {
expect(getStatusClasses("unknown", "accent", "dot")).toContain("pulse");
expect(getStatusClasses("unknown", "status", "dot")).toContain("pulse");
});
});
+11 -37
View File
@@ -4,7 +4,6 @@ import { ServerStatusStore } from "../../lib/server-status-store";
describe("ServerStatusStore", () => {
it("notifies only listeners for hosts whose status value changed", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1, 2, 3]));
const on1 = vi.fn();
const on2 = vi.fn();
@@ -43,7 +42,6 @@ describe("ServerStatusStore", () => {
it("does not notify when the status map is unchanged", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([1]));
store.applyStatuses(
new Map([[1, { status: "online", lastChecked: "t1" }]]),
);
@@ -57,47 +55,23 @@ describe("ServerStatusStore", () => {
expect(store.getStatus(1)).toBe("online");
});
it("returns offline for disabled hosts", () => {
it("calls a host it has no result for unknown, never offline", () => {
const store = new ServerStatusStore();
expect(store.getStatus(5)).toBe("unknown");
});
it("forgets everything on clear", () => {
const store = new ServerStatusStore();
store.applyStatuses(new Map([[5, { status: "online", lastChecked: "t" }]]));
expect(store.getStatus(5)).toBe("offline");
store.setEnabledHostIds(new Set([5]));
expect(store.getStatus(5)).toBe("online");
});
it("preserves reachable as distinct from authenticated online", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([7]));
store.applyStatuses(
new Map([[7, { status: "reachable", lastChecked: "t" }]]),
);
expect(store.getStatus(7)).toBe("reachable");
});
it("notifies when a status reason changes without a status change", () => {
const store = new ServerStatusStore();
store.setEnabledHostIds(new Set([7]));
store.applyStatuses(
new Map([[7, { status: "reachable", lastChecked: "t1" }]]),
);
store.setInitialLoadComplete(true);
const listener = vi.fn();
store.subscribeHost(7, listener);
store.subscribeHost(5, listener);
store.applyStatuses(
new Map([
[
7,
{
status: "reachable",
reason: "host_key_changed",
lastChecked: "t2",
},
],
]),
);
store.clear();
expect(store.getStatus(5)).toBe("unknown");
expect(store.getInitialLoadComplete()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getHostSnapshot(7)).toBe("reachable:host_key_changed");
});
it("emits meta listeners when initial load completes", () => {
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { Host, HostFolder } from "@/types/ui-types";
import { withLiveHostStatus, withLiveStatus } from "@/sidebar/live-host-status";
const host = (id: number, extra: Partial<Host> = {}) =>
({ id: String(id), name: `h${id}`, online: false, ...extra }) as Host;
const statuses: Record<number, "online" | "offline"> = {
1: "online",
2: "offline",
};
const getStatus = (id: number) => statuses[id] ?? "unknown";
describe("withLiveStatus", () => {
it("copies live statuses into nested folders", () => {
const tree: HostFolder = {
name: "root",
children: [host(1), { name: "f", children: [host(2), host(3)] }],
};
const live = withLiveStatus(tree, getStatus);
const [first, folder] = live.children as [Host, HostFolder];
const [second, third] = folder.children as Host[];
expect(first).toMatchObject({ status: "online", online: true });
expect(second).toMatchObject({ status: "offline", online: false });
expect(third).toMatchObject({ status: "unknown", online: false });
});
it("gives a host with status checks off no status", () => {
const [live] = withLiveHostStatus(
[host(1, { statusCheckEnabled: false })],
getStatus,
);
expect(live.status).toBeUndefined();
expect(live.online).toBe(false);
});
it("keeps the same object when nothing changed", () => {
const same = host(1, { status: "online", online: true });
expect(withLiveHostStatus([same], getStatus)[0]).toBe(same);
});
});
@@ -27,12 +27,11 @@ vi.mock("react-i18next", () => ({
vi.mock("@/lib/ServerStatusContext", () => ({
useHostStatus: () => null,
useHostStatusReason: () => null,
useServerStatus: () => ({
getStatus: () => "online",
initialLoadComplete: true,
}),
useServerStatusMeta: () => ({ initialLoadComplete: true, isLoading: false }),
useServerStatusMeta: () => ({ initialLoadComplete: true }),
}));
vi.mock("@/hooks/use-status-color-scheme", () => ({
@@ -27,24 +27,24 @@ function makeHost(overrides: Partial<Host> = {}): Host {
// not the key paths the translator falls back to when a resource is missing.
const t = (key: string): string =>
({
"hosts.status.available": "Available",
"hosts.status.reachable": "Reachable, not authenticated",
"hosts.status.online": "Online",
"hosts.status.checking": "Checking",
"hosts.status.offline": "Offline",
"hosts.status.monitoringDisabled": "Monitoring disabled",
})[key] ?? key;
describe("buildStatusTooltip", () => {
it("returns the translated 'Available' label for online status", () => {
it("returns the translated 'Online' label for online status", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toContain("Available");
expect(tooltip).toContain("Online");
expect(tooltip).not.toContain("hosts.status.");
});
it("returns the translated 'Reachable, not authenticated' label for reachable status", () => {
it("returns 'Checking' before the host has been checked", () => {
const host = makeHost();
const tooltip = buildStatusTooltip(host, "reachable", t);
expect(tooltip).toContain("Reachable, not authenticated");
const tooltip = buildStatusTooltip(host, "unknown", t);
expect(tooltip).toContain("Checking");
expect(tooltip).not.toContain("hosts.status.");
});
@@ -78,13 +78,13 @@ describe("buildStatusTooltip", () => {
dispose();
expect(tooltip).toContain("SSH");
expect(tooltip).toContain("Demo Desktop");
expect(tooltip).toContain("Available");
expect(tooltip).toContain("Online");
});
it("returns just the status label when no protocols are enabled", () => {
const host = makeHost({ enableSsh: false });
const tooltip = buildStatusTooltip(host, "online", t);
expect(tooltip).toBe("Available");
expect(tooltip).toBe("Online");
});
it("does not render key paths when a translator is supplied", () => {