mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
fix: simplify host status to online/offline and keep it live without a refresh
This commit is contained in:
1 parent
2646b649d7
commit
0a436ab3d8
48 files changed
+549
-789
No files matched your search
@@ -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<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
@@ -71,31 +72,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
let remoteHostsBySyncId: Map<string, number> | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
const hosts = response.data || [];
|
||||
localHostIds = hosts
|
||||
// A host-list failure throws here: sending no ids would stop every check.
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
const hosts = response.data || [];
|
||||
localHostIds = hosts
|
||||
.filter((host) => (host.connectionOrigin ?? defaultOrigin) === "local")
|
||||
.map((host) => host.id);
|
||||
remoteHostsBySyncId = new Map(
|
||||
hosts
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
(host) =>
|
||||
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
|
||||
!!host.syncId,
|
||||
)
|
||||
.map((host) => 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 = [];
|
||||
}
|
||||
.map((host) => [host.syncId as string, host.id]),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
@@ -128,10 +122,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
if (lastError) handleApiError(lastError, "fetch server statuses");
|
||||
|
||||
if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import {
|
||||
getCachedSSHHosts,
|
||||
invalidateHostsAndStatusCaches,
|
||||
@@ -10,42 +10,16 @@ import {
|
||||
// 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[]> {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
|
||||
}
|
||||
|
||||
export async function getSSHHosts(
|
||||
options: GetSSHHostsOptions = {},
|
||||
): Promise<SSHHostWithStatus[]> {
|
||||
const includeStatus = options.includeStatus !== false;
|
||||
|
||||
/** Host config only. Live status comes from ServerStatusContext. */
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
|
||||
|
||||
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",
|
||||
}));
|
||||
return hosts.map((host) => ({ ...host, status: "unknown" }));
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch SSH hosts");
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} 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 { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
|
||||
@@ -429,7 +433,7 @@ export function HostStatusCard({
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{online}/{hosts.length} {t("hosts.status.available")}
|
||||
{online}/{hosts.length} {t("hosts.status.online")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
@@ -439,12 +443,7 @@ export function HostStatusCard({
|
||||
</div>
|
||||
)}
|
||||
{hosts.map((host, i) => {
|
||||
const availability =
|
||||
host.status && host.status !== "unknown"
|
||||
? host.status
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
const availability = host.status ?? "offline";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -492,13 +491,11 @@ export function HostStatusCard({
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{statusLoading
|
||||
{statusLoading || availability === "unknown"
|
||||
? t("dashboardTab.checking")
|
||||
: availability === "online"
|
||||
? t("hosts.status.available")
|
||||
: availability === "reachable"
|
||||
? t("hosts.status.reachable")
|
||||
: t("hosts.status.offline")}
|
||||
? t("hosts.status.online")
|
||||
: t("hosts.status.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,7 +579,7 @@ function RecentActivityCard({
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<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">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
@@ -1102,7 +1099,14 @@ export function DashboardTab({
|
||||
}
|
||||
}, [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 [uptimeFormatted, setUptimeFormatted] = useState("");
|
||||
const [versionText, setVersionText] = useState("");
|
||||
|
||||
@@ -48,14 +48,13 @@ export function useStatusColorScheme(): StatusColorScheme {
|
||||
|
||||
/** Returns Tailwind class names for a status dot/stripe. */
|
||||
export function getStatusClasses(
|
||||
status: boolean | "online" | "reachable" | "offline" | "degraded",
|
||||
status: boolean | "online" | "offline" | "unknown",
|
||||
scheme: StatusColorScheme,
|
||||
variant: "dot" | "stripe" | "badge",
|
||||
loading = false,
|
||||
): string {
|
||||
const online = status === true || status === "online";
|
||||
const reachable = status === "reachable";
|
||||
if (loading) {
|
||||
if (loading || status === "unknown") {
|
||||
if (scheme === "status") {
|
||||
if (variant === "dot") return "bg-yellow-400 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";
|
||||
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 (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
|
||||
if (variant === "stripe")
|
||||
|
||||
@@ -9,7 +9,8 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
|
||||
import { getAllServerStatuses } from "@/main-axios";
|
||||
import { invalidateServerStatusCache } from "./hosts-request-cache";
|
||||
import {
|
||||
ServerStatusStore,
|
||||
type ServerStatusEntry,
|
||||
@@ -19,17 +20,26 @@ import { runAdaptivePolling } from "./adaptive-polling";
|
||||
|
||||
interface ServerStatusContextType {
|
||||
statuses: Map<number, ServerStatusEntry>;
|
||||
isLoading: boolean;
|
||||
initialLoadComplete: boolean;
|
||||
refreshStatuses: () => Promise<void>;
|
||||
/** "unknown" until the host has been checked. */
|
||||
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 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({
|
||||
children,
|
||||
@@ -47,121 +57,48 @@ export function ServerStatusProvider({
|
||||
// Bumps only full-context consumers (dashboard, folder counts, etc.).
|
||||
const [version, setVersion] = useState(0);
|
||||
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(() => {
|
||||
return store.subscribeAll(() => {
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
activeStore = store;
|
||||
return () => {
|
||||
if (activeStore === store) activeStore = null;
|
||||
};
|
||||
}, [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 () => {
|
||||
await refreshStatusesImpl();
|
||||
}, [refreshStatusesImpl]);
|
||||
if (!mountedRef.current || !isAuthenticated) return;
|
||||
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(
|
||||
(hostId: number): StatusValue => store.getStatus(hostId),
|
||||
@@ -170,35 +107,31 @@ export function ServerStatusProvider({
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (!isAuthenticated) {
|
||||
store.clear();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}
|
||||
|
||||
let stopPolling: (() => void) | null = null;
|
||||
|
||||
const init = async () => {
|
||||
await fetchEnabledHosts();
|
||||
if (!mountedRef.current) return;
|
||||
stopPolling = runAdaptivePolling(
|
||||
() => refreshStatusesImpl(true),
|
||||
{
|
||||
minIntervalMs: POLL_INTERVAL,
|
||||
maxIntervalMs: 120_000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ enabled: () => isAuthenticated },
|
||||
);
|
||||
};
|
||||
|
||||
void init();
|
||||
const stopPolling = runAdaptivePolling(refreshStatuses, {
|
||||
minIntervalMs: POLL_INTERVAL,
|
||||
maxIntervalMs: POLL_INTERVAL,
|
||||
});
|
||||
const onFocus = () => void refreshStatuses();
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopPolling?.();
|
||||
stopPolling();
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [fetchEnabledHosts, isAuthenticated, refreshStatusesImpl]);
|
||||
}, [isAuthenticated, refreshStatuses, store]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHostsChanged = async () => {
|
||||
await fetchEnabledHosts();
|
||||
await refreshStatuses();
|
||||
const handleHostsChanged = () => {
|
||||
invalidateServerStatusCache();
|
||||
void refreshStatuses();
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
@@ -208,17 +141,16 @@ export function ServerStatusProvider({
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
window.removeEventListener("hosts:refresh", handleHostsChanged);
|
||||
};
|
||||
}, [fetchEnabledHosts, refreshStatuses]);
|
||||
}, [refreshStatuses]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
statuses: store.getStatuses(),
|
||||
isLoading: store.getIsLoading(),
|
||||
initialLoadComplete: store.getInitialLoadComplete(),
|
||||
refreshStatuses,
|
||||
getStatus,
|
||||
}),
|
||||
// version refreshes statuses/isLoading/initialLoadComplete snapshots
|
||||
// version refreshes the statuses/initialLoadComplete snapshots
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[version, refreshStatuses, getStatus],
|
||||
);
|
||||
@@ -252,85 +184,49 @@ export function useServerStatus() {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* other hosts update.
|
||||
* status changes. Null when status checks are off for the host.
|
||||
*/
|
||||
export function useHostStatus(
|
||||
hostId: number,
|
||||
statusCheckEnabled: boolean = true,
|
||||
): StatusValue | null {
|
||||
const store = useStatusStore();
|
||||
|
||||
const snapshot = useSyncExternalStore(
|
||||
const status = useSyncExternalStore(
|
||||
(onChange) => store.subscribeHost(hostId, onChange),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getStatus(hostId),
|
||||
() => store.getStatus(hostId),
|
||||
);
|
||||
|
||||
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;
|
||||
return statusCheckEnabled ? status : null;
|
||||
}
|
||||
|
||||
/** Meta flags without depending on the full status map. */
|
||||
export function useServerStatusMeta(): {
|
||||
initialLoadComplete: boolean;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
export function useServerStatusMeta(): { initialLoadComplete: boolean } {
|
||||
const store = useStatusStore();
|
||||
|
||||
useSyncExternalStore(
|
||||
(onChange) => store.subscribeMeta(onChange),
|
||||
() => store.getMetaSnapshot(),
|
||||
() => store.getMetaSnapshot(),
|
||||
);
|
||||
|
||||
return {
|
||||
initialLoadComplete: store.getInitialLoadComplete(),
|
||||
isLoading: store.getIsLoading(),
|
||||
};
|
||||
return { initialLoadComplete: store.getInitialLoadComplete() };
|
||||
}
|
||||
|
||||
const noStatus = () => "";
|
||||
const noStatus = (): StatusValue => "unknown";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function useOptionalHostStatusEntry(hostId: number | undefined): {
|
||||
status: StatusValue;
|
||||
reason?: "host_key_changed";
|
||||
} | null {
|
||||
export function useOptionalHostStatusEntry(
|
||||
hostId: number | undefined,
|
||||
): { status: "online" | "offline" } | null {
|
||||
const store = useContext(StatusStoreContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
const status = useSyncExternalStore(
|
||||
(onChange) =>
|
||||
store && hostId !== undefined
|
||||
? store.subscribeHost(hostId, onChange)
|
||||
: () => {},
|
||||
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""),
|
||||
() => (store && hostId !== undefined ? store.getStatus(hostId) : "unknown"),
|
||||
noStatus,
|
||||
);
|
||||
if (!snapshot) return null;
|
||||
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 };
|
||||
return status === "unknown" ? null : { status };
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
export type StatusValue = "online" | "reachable" | "offline" | "degraded";
|
||||
export type StatusValue = "online" | "offline" | "unknown";
|
||||
|
||||
export interface ServerStatusEntry {
|
||||
status: StatusValue;
|
||||
status: "online" | "offline";
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -14,44 +13,26 @@ type Listener = () => void;
|
||||
*/
|
||||
export class ServerStatusStore {
|
||||
private statuses = new Map<number, ServerStatusEntry>();
|
||||
private enabledHostIds = new Set<number>();
|
||||
private initialLoadComplete = false;
|
||||
private isLoading = false;
|
||||
private readonly hostListeners = new Map<number, Set<Listener>>();
|
||||
private readonly allListeners = new Set<Listener>();
|
||||
private readonly metaListeners = new Set<Listener>();
|
||||
|
||||
/** "unknown" until the host has been checked. */
|
||||
getStatus(hostId: number): StatusValue {
|
||||
if (!this.enabledHostIds.has(hostId)) {
|
||||
return "offline";
|
||||
}
|
||||
return this.statuses.get(hostId)?.status || "degraded";
|
||||
return this.statuses.get(hostId)?.status ?? "unknown";
|
||||
}
|
||||
|
||||
getStatuses(): Map<number, ServerStatusEntry> {
|
||||
return this.statuses;
|
||||
}
|
||||
|
||||
getEnabledHostIds(): Set<number> {
|
||||
return this.enabledHostIds;
|
||||
}
|
||||
|
||||
getInitialLoadComplete(): boolean {
|
||||
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 {
|
||||
return `${this.initialLoadComplete ? 1 : 0}:${this.isLoading ? 1 : 0}:${this.enabledHostIds.size}`;
|
||||
return this.initialLoadComplete ? "1" : "0";
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.initialLoadComplete === complete) return;
|
||||
this.initialLoadComplete = complete;
|
||||
@@ -120,50 +79,24 @@ export class ServerStatusStore {
|
||||
const changedIds: number[] = [];
|
||||
|
||||
for (const [id, entry] of next) {
|
||||
const prev = this.statuses.get(id);
|
||||
if (
|
||||
!prev ||
|
||||
prev.status !== entry.status ||
|
||||
prev.reason !== entry.reason
|
||||
) {
|
||||
changedIds.push(id);
|
||||
}
|
||||
if (this.statuses.get(id)?.status !== entry.status) changedIds.push(id);
|
||||
}
|
||||
for (const id of this.statuses.keys()) {
|
||||
if (!next.has(id)) {
|
||||
changedIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedIds.length === 0) {
|
||||
// Still refresh lastChecked silently without notifying.
|
||||
this.statuses = next;
|
||||
return;
|
||||
if (!next.has(id)) changedIds.push(id);
|
||||
}
|
||||
|
||||
this.statuses = next;
|
||||
if (changedIds.length === 0) return;
|
||||
for (const id of changedIds) {
|
||||
this.emitHost(id);
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
markDegraded(enabledIds: Iterable<number>): void {
|
||||
const next = new Map(this.statuses);
|
||||
let changed = false;
|
||||
const now = new Date().toISOString();
|
||||
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);
|
||||
}
|
||||
/** Forgets everything, on sign out. */
|
||||
clear(): void {
|
||||
this.applyStatuses(new Map());
|
||||
this.setInitialLoadComplete(false);
|
||||
}
|
||||
|
||||
private emitHost(hostId: number): void {
|
||||
@@ -180,11 +113,3 @@ export class ServerStatusStore {
|
||||
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;
|
||||
}
|
||||
@@ -122,7 +122,6 @@
|
||||
"minimize": "Minimize",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Sign in failed",
|
||||
"unknown": "Unknown",
|
||||
"continue": "Continue",
|
||||
"maintenance": "Maintenance",
|
||||
@@ -358,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Host Metrics",
|
||||
"status": {
|
||||
"available": "Available",
|
||||
"reachable": "Available, but login failed",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitoring disabled",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
|
||||
@@ -63,13 +63,12 @@ import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
|
||||
export type ServerStatus = {
|
||||
status: "online" | "reachable" | "offline";
|
||||
status: "online" | "offline";
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
};
|
||||
|
||||
export type SSHHostWithStatus = SSHHost & {
|
||||
status: "online" | "reachable" | "offline" | "unknown";
|
||||
status: "online" | "offline" | "unknown";
|
||||
};
|
||||
|
||||
export interface AuthResponse {
|
||||
|
||||
@@ -46,7 +46,10 @@ import { invokeAction } from "@/shell/action-registry";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
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
|
||||
@@ -370,12 +373,7 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
usePluginComponent: (id) => usePluginComponent(id),
|
||||
|
||||
useHostStatus: (hostId) => {
|
||||
const entry = useOptionalHostStatusEntry(hostId);
|
||||
if (!entry) return null;
|
||||
return {
|
||||
status: entry.status === "degraded" ? "unknown" : entry.status,
|
||||
...(entry.reason ? { reason: entry.reason } : {}),
|
||||
};
|
||||
return useOptionalHostStatusEntry(hostId);
|
||||
},
|
||||
|
||||
useHostActions: () => useHostActions() as unknown as HostActionContribution[],
|
||||
@@ -429,9 +427,13 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
void setCookie(name, value, 365);
|
||||
},
|
||||
listHosts: async (pluginId) =>
|
||||
(await getSSHHosts({ includeStatus: false })).map((host) =>
|
||||
toPluginHostRecord(host, pluginId ?? null),
|
||||
),
|
||||
(await getSSHHosts()).map((host) => {
|
||||
const status = getLiveHostStatus(host.id);
|
||||
return toPluginHostRecord(
|
||||
{ ...host, status, online: status === "online" },
|
||||
pluginId ?? null,
|
||||
);
|
||||
}),
|
||||
notifyHostsChanged: () => {
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./palette-registry";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
isOpen: boolean;
|
||||
@@ -501,7 +502,7 @@ export function CommandPalette({
|
||||
<Server
|
||||
className={cn(
|
||||
"size-4",
|
||||
host.online
|
||||
getLiveHostStatus(Number(host.id)) === "online"
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
@@ -517,7 +518,8 @@ export function CommandPalette({
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
)}
|
||||
{host.online && (
|
||||
{getLiveHostStatus(Number(host.id)) ===
|
||||
"online" && (
|
||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function AdminSettingsPanel({
|
||||
const [passwordLoginForced, setPasswordLoginForced] = useState(false);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
|
||||
const [sessionTimeout, setSessionTimeout] = useState("24");
|
||||
const [statusInterval, setStatusInterval] = useState("60");
|
||||
const [statusInterval, setStatusInterval] = useState("30");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
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 { useArrangeLock } from "@/sidebar/use-arrange-lock";
|
||||
import type {
|
||||
@@ -137,9 +139,7 @@ function hostPassesFilters(
|
||||
if (filters.status.length > 0) {
|
||||
const ok =
|
||||
(filters.status.includes("online") && host.online) ||
|
||||
(filters.status.includes("offline") &&
|
||||
host.status !== "reachable" &&
|
||||
!host.online) ||
|
||||
(filters.status.includes("offline") && host.status === "offline") ||
|
||||
(filters.status.includes("pinned") && !!host.pin);
|
||||
if (!ok) return false;
|
||||
}
|
||||
@@ -219,6 +219,13 @@ export function HostsPanel({
|
||||
active?: boolean;
|
||||
}) {
|
||||
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(
|
||||
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
|
||||
);
|
||||
@@ -1028,10 +1035,10 @@ export function HostsPanel({
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<SidebarTree
|
||||
children={
|
||||
hostTree
|
||||
liveHostTree
|
||||
? groupHosts(
|
||||
applyFilters(
|
||||
sortHostTree(hostTree, sortKey, pinnedFirst),
|
||||
sortHostTree(liveHostTree, sortKey, pinnedFirst),
|
||||
filterState,
|
||||
hostSwitches,
|
||||
),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -15,23 +15,12 @@ import { HostItem, statusCheckEnabled } from "../HostItem/HostItem";
|
||||
import { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
|
||||
import { FolderActions } from "./FolderActions";
|
||||
|
||||
export function folderHostCount(folder: HostFolder): {
|
||||
total: number;
|
||||
online: number;
|
||||
} {
|
||||
let total = 0,
|
||||
online = 0;
|
||||
export function folderHostCount(folder: HostFolder): number {
|
||||
let total = 0;
|
||||
for (const child of folder.children) {
|
||||
if (isFolder(child)) {
|
||||
const c = folderHostCount(child);
|
||||
total += c.total;
|
||||
online += c.online;
|
||||
} else {
|
||||
total++;
|
||||
if (child.online) online++;
|
||||
}
|
||||
total += isFolder(child) ? folderHostCount(child) : 1;
|
||||
}
|
||||
return { total, online };
|
||||
return total;
|
||||
}
|
||||
|
||||
export function FolderItem({
|
||||
@@ -140,13 +129,11 @@ export function FolderItem({
|
||||
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { getStatus, initialLoadComplete } = useServerStatus();
|
||||
const { total } = folderHostCount(folder);
|
||||
const online = initialLoadComplete
|
||||
? collectAllHosts(folder.children).filter(
|
||||
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
|
||||
).length
|
||||
: folderHostCount(folder).online;
|
||||
const { getStatus } = useServerStatus();
|
||||
const total = folderHostCount(folder);
|
||||
const online = collectAllHosts(folder.children).filter(
|
||||
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
|
||||
).length;
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
|
||||
|
||||
|
||||
@@ -59,11 +59,7 @@ import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import {
|
||||
useHostStatus,
|
||||
useHostStatusReason,
|
||||
useServerStatusMeta,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
import { useHostStatus } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -101,16 +97,16 @@ export function statusCheckEnabled(host: Host): boolean {
|
||||
|
||||
export function buildStatusTooltip(
|
||||
host: Host,
|
||||
status: "online" | "reachable" | "offline",
|
||||
status: "online" | "offline" | "unknown",
|
||||
t: (key: string) => string = (k) => k,
|
||||
): string {
|
||||
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
|
||||
const statusLabel =
|
||||
status === "online"
|
||||
? t("hosts.status.available")
|
||||
: status === "reachable"
|
||||
? t("hosts.status.reachable")
|
||||
: t("hosts.status.offline");
|
||||
if (!statusCheckEnabled(host)) return t("hosts.status.monitoringDisabled");
|
||||
? t("hosts.status.online")
|
||||
: status === "offline"
|
||||
? t("hosts.status.offline")
|
||||
: t("hosts.status.checking");
|
||||
const protocols: string[] = [];
|
||||
if (host.enableSsh) protocols.push("SSH");
|
||||
for (const protocol of enabledHostProtocols(host)) {
|
||||
@@ -283,22 +279,11 @@ export function HostItem({
|
||||
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
|
||||
const splitTargets = useSplitTargets();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const { initialLoadComplete } = useServerStatusMeta();
|
||||
const statusCheckOn = statusCheckEnabled(host);
|
||||
const statusLoading = !initialLoadComplete && statusCheckOn;
|
||||
// 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);
|
||||
// Per-host subscription, so a poll only re-renders rows that flipped.
|
||||
const availability =
|
||||
liveStatus === "online" ||
|
||||
liveStatus === "reachable" ||
|
||||
liveStatus === "offline"
|
||||
? liveStatus
|
||||
: host.status === "reachable"
|
||||
? "reachable"
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
useHostStatus(Number(host.id), statusCheckOn) ?? "offline";
|
||||
const statusLoading = availability === "unknown";
|
||||
const isOnline = availability === "online";
|
||||
const previousAvailability = useRef(availability);
|
||||
const [statusLocking, setStatusLocking] = useState(false);
|
||||
@@ -1136,9 +1121,7 @@ export function HostItem({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{statusReason === "host_key_changed"
|
||||
? `${t("hostKey.keyChangedWarning")}: ${t("hostKey.keyChangedDescription")}`
|
||||
: buildStatusTooltip(host, availability, t)}
|
||||
{buildStatusTooltip(host, availability, t)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -502,7 +502,7 @@ export function SidebarTree({
|
||||
|
||||
function handleDeleteFolder(folder: HostFolder) {
|
||||
const folderPath = folder.path ?? folder.name;
|
||||
const { total } = folderHostCount(folder);
|
||||
const total = folderHostCount(folder);
|
||||
setConfirmDialog({
|
||||
message: t("hosts.deleteFolderConfirm", {
|
||||
name: folder.name,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user