mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 09:35:24 +00:00
232 lines
6.5 KiB
TypeScript
232 lines
6.5 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
hostId: number;
|
|
state: HostReadState;
|
|
value?: T;
|
|
error?: string;
|
|
fetchedAt?: string;
|
|
}
|
|
|
|
const cache = new Map<string, CacheEntry>();
|
|
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<string, unknown>;
|
|
origin: ActorOrigin;
|
|
}
|
|
|
|
function refreshHost(
|
|
options: ReadOptions,
|
|
hostId: number,
|
|
key: string,
|
|
): Promise<unknown> {
|
|
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<T>(
|
|
options: ReadOptions,
|
|
): Promise<HostRead<T>[]> {
|
|
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<T>[] = [];
|
|
|
|
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<string, unknown>;
|
|
}
|
|
|
|
/** Flattens per host reads into table rows, keeping each host's state. */
|
|
export function flattenResourceRows(
|
|
reads: HostRead<ResourceListResult>[],
|
|
keyField: string,
|
|
): { rows: ResourceRow[]; hosts: Omit<HostRead<unknown>, "value">[] } {
|
|
const rows: ResourceRow[] = [];
|
|
const hosts: Omit<HostRead<unknown>, "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();
|
|
}
|