Improve reconnect status feedback (#2931)
* fix(app): debounce reconnect status toast Keep reconnect transport immediate while delaying the visible status toast so brief outages stay quiet. Scope the toast to the active retained panel, keep its copy and status dot consistent across locales, and add a manual real relay deployment journey that verifies streaming recovery without user action. * fix(app): isolate manual relay deployment check Keep the deployment journey out of the provider-wide real test project so it runs only when its local relay prerequisites are explicitly requested. Also keep the relay teardown active when packaged daemon startup or later cleanup fails. * fix(app): preserve reconnect toast across tabs Keep reconnect presentation timing at the host level so switching between retained panels does not restart the debounce or flash the toast. Add a browser regression covering a visible outage across retained tab switches. * fix(app): anchor reconnect debounce to host status Record host connection status transitions independently of mounted agent panels and preserve the outage start across reconnecting states. Use that host-level timestamp when a visible agent panel appears, with runtime coverage for the transition lifecycle. * fix(app): reset reconnect toast state per outage * test(app): build relay journey prerequisites
This commit is contained in:
@@ -1639,6 +1639,50 @@ describe("HostRuntimeStore", () => {
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("tracks connection status transitions independently of agent panels", async () => {
|
||||
useHostRuntimeClock();
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host: hostProfile }) => ({
|
||||
client: fakeClient as unknown as DaemonClient,
|
||||
serverId: hostProfile.serverId,
|
||||
hostname: hostProfile.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
store.syncHosts([host]);
|
||||
await fakeClient.waitForFetches(1);
|
||||
await waitForDirectoryReady(store, host.serverId);
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
const outageStartedAt = Date.now();
|
||||
fakeClient.setConnectionState({ status: "disconnected", reason: "transport closed" });
|
||||
expect(store.getConnectionStatusSince(host.serverId)).toBe(outageStartedAt);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(store.getConnectionStatusSince(host.serverId)).toBe(outageStartedAt);
|
||||
|
||||
const reconnectStartedAt = Date.now();
|
||||
fakeClient.setConnectionState({ status: "connected" });
|
||||
expect(store.getConnectionStatusSince(host.serverId)).toBe(reconnectStartedAt);
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("bootstraps agent directory subscription when host transitions online", async () => {
|
||||
const host = makeHost({
|
||||
connections: [
|
||||
|
||||
@@ -1358,6 +1358,7 @@ export class HostRuntimeStore {
|
||||
private hostRegistryStatus: HostRegistryStatus = "loading";
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private connectionStatusStartedAtByServer = new Map<string, number>();
|
||||
private directoryBootstrapInFlight = new Map<string, Promise<void>>();
|
||||
private queuedAgentDrainInFlight = new Set<string>();
|
||||
private directorySyncByServer = new Map<string, DirectorySync>();
|
||||
@@ -1593,6 +1594,7 @@ export class HostRuntimeStore {
|
||||
|
||||
rekeyMap(this.controllers, oldServerId, newServerId);
|
||||
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
|
||||
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||
@@ -1956,6 +1958,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
this.controllers.delete(serverId);
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.directoryBootstrapInFlight.delete(serverId);
|
||||
this.directorySyncByServer.get(serverId)?.dispose();
|
||||
this.directorySyncByServer.delete(serverId);
|
||||
@@ -1991,10 +1994,9 @@ export class HostRuntimeStore {
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
}),
|
||||
);
|
||||
this.lastConnectionStatusByServer.set(
|
||||
host.serverId,
|
||||
controller.getSnapshot().connectionStatus,
|
||||
);
|
||||
const initialSnapshot = controller.getSnapshot();
|
||||
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
|
||||
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
|
||||
controller.subscribe(() => {
|
||||
const snapshot = controller.getSnapshot();
|
||||
this.syncSessionReplica(snapshot.serverId, snapshot);
|
||||
@@ -2035,6 +2037,7 @@ export class HostRuntimeStore {
|
||||
const controller = this.controllers.get(serverId);
|
||||
if (!controller) {
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.directoryBootstrapInFlight.delete(serverId);
|
||||
return;
|
||||
}
|
||||
@@ -2049,6 +2052,14 @@ export class HostRuntimeStore {
|
||||
},
|
||||
}) ?? false;
|
||||
const previousStatus = this.lastConnectionStatusByServer.get(serverId);
|
||||
const statusChanged = previousStatus !== snapshot.connectionStatus;
|
||||
const isUnavailable =
|
||||
snapshot.connectionStatus !== "online" && snapshot.connectionStatus !== "idle";
|
||||
const wasUnavailable =
|
||||
previousStatus !== undefined && previousStatus !== "online" && previousStatus !== "idle";
|
||||
if (statusChanged && (!wasUnavailable || !isUnavailable)) {
|
||||
this.connectionStatusStartedAtByServer.set(serverId, Date.now());
|
||||
}
|
||||
this.lastConnectionStatusByServer.set(serverId, snapshot.connectionStatus);
|
||||
const didTransitionOnline =
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
@@ -2160,6 +2171,10 @@ export class HostRuntimeStore {
|
||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||
}
|
||||
|
||||
getConnectionStatusSince(serverId: string): number | null {
|
||||
return this.connectionStatusStartedAtByServer.get(serverId) ?? null;
|
||||
}
|
||||
|
||||
getVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
@@ -2319,6 +2334,10 @@ export function getHostRuntimeStore(): HostRuntimeStore {
|
||||
return singletonHostRuntimeStore;
|
||||
}
|
||||
|
||||
export function getHostRuntimeConnectionStatusSince(serverId: string): number | null {
|
||||
return getHostRuntimeStore().getConnectionStatusSince(serverId);
|
||||
}
|
||||
|
||||
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
const store = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
|
||||
Reference in New Issue
Block a user