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

@@ -88,7 +88,7 @@ beforeEach(() => {
state.statuses = new Map([
[1, { status: "online", lastChecked: "t" }],
[2, { status: "offline", lastChecked: "t" }],
[3, { status: "reachable", lastChecked: "t" }],
[3, { status: "offline", lastChecked: "t" }],
]);
service.statusesFor.mockImplementation(async () => state.statuses);
service.get.mockImplementation(
@@ -55,6 +55,7 @@ function setup(
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
});
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
}
@@ -192,86 +193,44 @@ describe("HostStatusService", () => {
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
});
it("warns about a failed login until a login works again", async () => {
it("ignores a failed login and counts a working one as online", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
const before = emitted.length;
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("reachable");
expect(emitted.at(-1)).toMatchObject({
status: "reachable",
previous: "online",
});
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("reachable");
service.reportLogin(1, { ok: true });
expect(service.get(1)?.status).toBe("online");
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("online");
expect(emitted).toHaveLength(before);
});
it("marks a changed host key and clears it once the new key is accepted", async () => {
const { service } = setup([target(1)]);
it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)], false);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false, hostKeyChanged: true });
expect(service.get(1)).toMatchObject({
status: "reachable",
reason: "host_key_changed",
});
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
expect(service.get(1)?.reason).toBeUndefined();
expect(service.get(1)?.status).toBe("online");
});
it("clears a failed login when the host is edited", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 });
await flush();
await flush();
expect(service.get(1)?.status).toBe("online");
});
it("clears a failed login when a terminal session opens", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("offline");
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
});
it("tries a failed ping again before reporting offline", async () => {
const { service, ping } = setup([target(1)]);
active = service;
ping.mockResolvedValueOnce(false);
await service.statusesFor("owner", null);
expect(ping).toHaveBeenCalledTimes(2);
expect(service.get(1)?.status).toBe("online");
});
it("shows an offline host as offline whatever the login said", async () => {
const { service } = setup([target(1)], false);
it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("offline");
service.reportLogin(1, { ok: true });
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("offline");
const statuses = await service.statusesFor("owner", null);
expect(statuses.get(1)?.status).toBe("online");
expect(statuses.get(2)?.status).toBe("online");
});
it("only checks the requested hosts on the desktop app", async () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../hosts/status/host-status.js";
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
@@ -19,24 +15,3 @@ describe("isHostKeyVerificationError", () => {
);
});
});
describe("host availability status", () => {
it("calls a host whose port answers online", () => {
expect(statusAfterReachabilityCheck(true, false)).toBe("online");
expect(statusAfterReachabilityCheck(false, false)).toBe("offline");
});
it("warns about a reachable host whose last login failed", () => {
expect(statusAfterReachabilityCheck(true, true)).toBe("reachable");
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
});
it("marks successful authentication online", () => {
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
});
it("downgrades failed authentication without hiding reachability", () => {
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
});
});
@@ -67,6 +67,29 @@ describe("JumpProbePool", () => {
pool.closeAll();
});
it("drops a chain whose hop stops answering", async () => {
vi.useFakeTimers();
const stale = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(),
});
const fresh = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(fresh);
const pool = new JumpProbePool(open);
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
await vi.advanceTimersByTimeAsync(1000);
expect(await first).toBe(false);
expect(stale.end).toHaveBeenCalled();
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
expect(open).toHaveBeenCalledTimes(2);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
+5 -7
View File
@@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
get: (hostId: number) =>
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({
status: "offline",
lastChecked: String(hostId),
@@ -496,7 +496,7 @@ describe("ctx.hosts", () => {
}
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
expect(record.pluginSettings).toEqual({ mine: "own-4" });
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
expect(record.status).toEqual({ status: "online", lastChecked: "t" });
expect(record.enableSsh).toBe(true);
});
@@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => {
audit: vi.fn(async () => {}),
});
await expect(hosts.status.get(4)).resolves.toEqual({
status: "reachable",
status: "online",
lastChecked: "t",
});
await expect(hosts.status.get(5)).resolves.toBeNull();
@@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => {
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
hosts.status.reportLogin(4, { ok: true });
await settle();
expect(h.statusReports).toEqual([
{ hostId: 4, ok: false, hostKeyChanged: true },
]);
expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
});
it("removes a registered port when the plugin is disposed", async () => {
@@ -220,24 +220,19 @@ describe("ctx.ssh", () => {
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
});
it("reports an auth or host key failure, but not a timeout", async () => {
it("never reports a failed login to the status", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
h.connectError = new Error("Timed out while waiting for handshake");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("All configured authentication methods failed");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("Host key changed");
await expect(ssh.connect(8)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins).toEqual([
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
]);
expect(h.logins).toEqual([]);
});
it("never believes a host object's own userId", async () => {