/** * Reads for All hosts tables and status tiles. * * Asking every host over SSH on each page load cannot hold up, so a request * answers right away from memory and refreshes stale hosts behind it. The page * polls and the table fills in as hosts answer. Results stay in memory, never * SQLite, since a save rewrites the whole database. * * Keys include the user: someone a host is shared with can have their own * login there and see different rows. */ import type { ResourceListResult, SignalResult, SignalStatus, } from "@termix-ssh/plugin-sdk/operations"; import { RESOURCE_FRESH_MS } from "./constants.js"; import { coalesce } from "./limits.js"; import { getAction } from "./registry.js"; import { matchesWhen } from "@termix-ssh/plugin-sdk/operations"; import { loadOperationHost } from "./hosts.js"; import type { ActorOrigin } from "../plugins/actor.js"; import { hostStatus } from "../plugins/host-status-provider.js"; import { PermissionManager } from "../utils/permission-manager.js"; import { loadProbeTargets } from "../plugins/ctx-probe.js"; export type HostReadState = "ok" | "loading" | "offline" | "error" | "no-access" | "not-applicable"; interface CacheEntry { value: unknown; error?: string; fetchedAt: number; } export interface HostRead { hostId: number; state: HostReadState; value?: T; error?: string; fetchedAt?: string; } const cache = new Map(); const MAX_ENTRIES = 20_000; function cacheKey(userId: string, actionId: string, hostId: number): string { return `${userId}|${actionId}|${hostId}`; } /** After a change, the next read of this host goes back to the host. */ export function dropResourceCache(hostId: number): void { const suffix = `|${hostId}`; for (const key of cache.keys()) { if (key.endsWith(suffix)) cache.delete(key); } } function remember(key: string, entry: CacheEntry): void { if (cache.size >= MAX_ENTRIES) { const oldest = cache.keys().next().value; if (oldest) cache.delete(oldest); } cache.delete(key); cache.set(key, entry); } function isOffline(hostId: number): boolean { const status = hostStatus.get(hostId) as string | { status?: string } | null | undefined; const value = typeof status === "string" ? status : status?.status; return value === "offline"; } interface ReadOptions { userId: string; actionId: string; hostIds?: number[]; refresh?: boolean; input?: Record; origin: ActorOrigin; } function refreshHost( options: ReadOptions, hostId: number, key: string, ): Promise { return coalesce(key, async () => { const { runOperation, OperationError } = await import("./gate.js"); try { const outcome = await runOperation({ actionId: options.actionId, hostId, input: options.input, internal: true, userId: options.userId, origin: options.origin, }); if (outcome.status !== "success") { remember(key, { value: undefined, error: outcome.error ?? outcome.status, fetchedAt: Date.now(), }); return; } remember(key, { value: outcome.data, fetchedAt: Date.now() }); } catch (error) { remember(key, { value: undefined, error: error instanceof OperationError || error instanceof Error ? error.message : String(error), fetchedAt: Date.now(), }); } }); } /** One entry per host the user can reach. Never waits on a host. */ export async function readAcrossHosts( options: ReadOptions, ): Promise[]> { const registration = getAction(options.actionId); if (!registration) return []; const { action } = registration; const { getPluginRuntime } = await import("../plugins/index.js"); const manifest = getPluginRuntime().loader.get( registration.pluginId, )?.manifest; if (!manifest) return []; const permissions = PermissionManager.getInstance(); const targets = await loadProbeTargets(options.userId, options.hostIds); const reads: HostRead[] = []; for (const target of targets) { const hostId = target.id; const access = await permissions.canAccessHost( options.userId, hostId, action.hostAccess ?? "connect", ); if (!access.hasAccess) { reads.push({ hostId, state: "no-access" }); continue; } if (action.when) { const host = await loadOperationHost(hostId, manifest); if (!host || !matchesWhen(action.when, host)) continue; } const key = cacheKey(options.userId, options.actionId, hostId); const cached = cache.get(key); const fresh = cached && Date.now() - cached.fetchedAt < RESOURCE_FRESH_MS; if (isOffline(hostId)) { reads.push({ hostId, state: "offline", value: cached?.value as T | undefined, fetchedAt: cached ? new Date(cached.fetchedAt).toISOString() : undefined, }); continue; } if (!fresh || options.refresh) void refreshHost(options, hostId, key); if (!cached) { reads.push({ hostId, state: "loading" }); } else if (cached.error) { reads.push({ hostId, state: "error", error: cached.error, fetchedAt: new Date(cached.fetchedAt).toISOString(), }); } else { reads.push({ hostId, state: "ok", value: cached.value as T, fetchedAt: new Date(cached.fetchedAt).toISOString(), }); } } return reads; } export interface ResourceRow { hostId: number; key: string; row: Record; } /** Flattens per host reads into table rows, keeping each host's state. */ export function flattenResourceRows( reads: HostRead[], keyField: string, ): { rows: ResourceRow[]; hosts: Omit, "value">[] } { const rows: ResourceRow[] = []; const hosts: Omit, "value">[] = []; for (const read of reads) { const { value, ...state } = read; hosts.push(state); for (const row of value?.rows ?? []) { rows.push({ hostId: read.hostId, key: String(row[keyField]), row }); } } return { rows, hosts }; } const WORST: SignalStatus[] = ["fail", "warn", "unknown", "ok"]; /** The worst status across hosts, for a server level tile. */ export function worstStatus( results: (SignalResult | undefined)[], ): SignalStatus { for (const status of WORST) { if (results.some((result) => result?.status === status)) return status; } return "unknown"; } /** Test seam. */ export function clearResourceCache(): void { cache.clear(); }