fix: keep instance statuses between switcher opens

The switcher held reachability in component state and replaced the whole map at
the end of a probe run. It ran once per open before the config had loaded — with
Local as the only host — so that pass wiped every other instance's status and
each open started on "Checking", including for the instance the app was
connected to and actively talking to.

Statuses move to their own module: startup warms them so the switcher opens on
real values, a re-probe replaces each value in place as it lands rather than
blanking them first, and stale entries are dropped against the loaded config
instead of a partial host list. The connected instance never reads "Checking" —
the live connection already answers what the probe would ask.
This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent 8f1e0c8b5f
commit 17fd46d4e5
4 changed files with 313 additions and 62 deletions
@@ -14,6 +14,7 @@ import { toast } from '@/components/ui';
import { isElectronShell, isDesktopShell } from '@/lib/desktop';
import { Icon } from "@/components/icon/Icon";
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useI18n } from '@/lib/i18n';
import {
desktopHostProbe,
@@ -37,6 +38,14 @@ import {
resolveCurrentDesktopHost,
runtimeKeyForDesktopHost,
} from '@/lib/desktopCurrentHost';
import {
getDesktopHostStatusSnapshot,
probeDesktopHosts,
setDesktopHostStatus,
pruneDesktopHostStatuses,
subscribeDesktopHostStatuses,
type DesktopHostStatus,
} from '@/lib/desktopHostStatus';
import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore';
import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
@@ -52,17 +61,7 @@ import {
const SSH_CONNECT_TIMEOUT_MS = 90_000;
const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled';
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
/** Which transport the successful probe used (multi-transport hosts). */
via?: 'relay';
};
// Last known statuses survive the dropdown unmounting (it remounts on every
// open). Rows show the previous result immediately — refreshed quietly by the
// open-probe — instead of shouting "Unknown" at the user for a few seconds.
const lastKnownHostStatuses: Record<string, HostStatus> = {};
type HostStatus = DesktopHostStatus;
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
@@ -247,15 +246,17 @@ export function DesktopHostSwitcherDialog({
const { t } = useI18n();
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isRuntimeConnected = useConfigStore((state) => state.isConnected);
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>(() => ({ ...lastKnownHostStatuses }));
React.useEffect(() => {
Object.assign(lastKnownHostStatuses, statusById);
}, [statusById]);
// Statuses live outside this component: startup warms them, and the dropdown
// remounts on every open — holding them here is what made each open start
// from nothing and show "Checking" on rows the app already knew about.
const statusSnapshot = React.useSyncExternalStore(subscribeDesktopHostStatuses, getDesktopHostStatusSnapshot, getDesktopHostStatusSnapshot);
const statusById = statusSnapshot.byHostId;
const isProbing = statusSnapshot.isProbing;
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [switchingHostId, setSwitchingHostId] = React.useState<string | null>(null);
const [sshHostIds, setSshHostIds] = React.useState<Record<string, true>>({});
@@ -347,6 +348,10 @@ export function DesktopHostSwitcherDialog({
nextSshHostIds[instance.id] = true;
}
setConfigHosts(cfg.hosts || []);
// Config is the authoritative host list: drop statuses for instances the
// user removed. Doing this from a probe run instead would clear entries
// every time a run started before the config had finished loading.
pruneDesktopHostStatuses((cfg.hosts || []).map((host) => host.id));
setDefaultHostId(cfg.defaultHostId ?? null);
setSshHostIds(nextSshHostIds);
setSshStatusesById(sshStatusMap);
@@ -362,43 +367,7 @@ export function DesktopHostSwitcherDialog({
}, [t]);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isDesktopShell()) return;
setIsProbing(true);
try {
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const probeRelayLeg = async (): Promise<HostStatus> => {
const res = await probeRelayDesktopHost(h.relay!, { clientToken, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) };
};
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
if (h.relay && !h.apiUrl) {
return [h.id, await probeRelayLeg()] as const;
}
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Multi-transport host away from its network: the direct leg fails
// but the relay may still reach it.
if (isBlockedHostStatus(res.status) && h.relay) {
const relayStatus = await probeRelayLeg();
if (relayStatus.status === 'ok') return [h.id, relayStatus] as const;
}
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
const next: Record<string, HostStatus> = {};
for (const [id, val] of results) {
next[id] = val;
}
setStatusById(next);
} finally {
setIsProbing(false);
}
await probeDesktopHosts(hosts);
}, []);
React.useEffect(() => {
@@ -514,7 +483,7 @@ export function DesktopHostSwitcherDialog({
relayProbeTunnel = 'tunnel' in probe ? probe.tunnel : undefined;
}
}
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
setDesktopHostStatus(host.id, finalStatus);
if (!transport) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
@@ -620,10 +589,7 @@ export function DesktopHostSwitcherDialog({
if (host.id !== LOCAL_HOST_ID && isDesktopShell()) {
setSwitchingHostId(host.id);
const probe = await desktopHostProbe(origin, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
setDesktopHostStatus(host.id, { status: probe.status, latencyMs: probe.latencyMs });
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
@@ -863,12 +829,17 @@ export function DesktopHostSwitcherDialog({
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
// While a probe runs, keep showing the last known result (quiet
// refresh); only fall back to "Checking" when there has never
// been one. "Unknown" is never shown — an unprobed host is by
// definition being checked.
// refresh — the header's refresh icon is the spinner); only fall
// back to "Checking" when there has never been one. "Unknown" is
// never shown — an unprobed host is by definition being checked.
//
// The instance the app is connected to never says "Checking":
// the live connection already answers the question a probe would
// ask, and reporting otherwise reads as the app not knowing where
// it is. A real probe result still wins — it carries the ping.
const statusKind: HostDisplayStatus = isSsh
? sshPhaseToHostStatus(sshStatus?.phase)
: (status?.status ?? 'checking');
: (status?.status ?? (isActive && isRuntimeConnected ? 'ok' : 'checking'));
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
const displayLabel = host.id === LOCAL_HOST_ID