feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

@@ -1,81 +0,0 @@
/**
* One-line hand-off from the metrics poller onto the ctx.events bus.
*
* The poller is already a very large module, so the hooks it calls live here
* instead. Everything is fire-and-forget: a failure in a subscriber must never
* disturb metric collection, and publishing onto the bus creates no static edge
* to the automations layer (which reads repositories the metrics module also
* pulls in).
*
* For the generic (non-metrics) internal event notifier, see
* `hosts/automation-events.ts` -- that one is shared across features.
*/
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
import type { MetricsSnapshot } from "../../../automations/src/backend/conditions.js";
export interface HostMetricsPayload {
hostId: number;
ownerUserId: string;
metrics: MetricsSnapshot;
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
online: boolean;
}
export interface HostHealthCheckPayload {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export function notifyAutomationMetrics(
hostId: number,
ownerUserId: string,
metrics: MetricsSnapshot,
): void {
if (!ownerUserId) return;
pluginEvents.emit(TOPICS.hostMetrics, {
hostId,
ownerUserId,
metrics,
} satisfies HostMetricsPayload);
}
export function notifyAutomationStatus(
hostId: number,
ownerUserId: string,
online: boolean,
): void {
if (!ownerUserId) return;
pluginEvents.emit(TOPICS.hostStatus, {
hostId,
ownerUserId,
online,
} satisfies HostStatusPayload);
}
export function notifyAutomationHealthCheck(
hostId: number,
userId: string,
checkId: string,
ok: boolean,
detail?: string,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.hostHealthCheck, {
hostId,
userId,
checkId,
ok,
detail,
} satisfies HostHealthCheckPayload);
}
+196
View File
@@ -0,0 +1,196 @@
import type { Client } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import { detectHostPlatform } from "@termix/plugin-sdk/host-commands";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { collectDiskMetrics } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import type { FirewallMetrics, PortsMetrics } from "../shared/stats-widgets.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
import type { MetricsState } from "./state.js";
import type { CollectorRegistry } from "./collectors.js";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("Host denied (verification failed)") ||
error.message.includes("Host key changed"))
);
}
export type CollectedMetrics = {
cpu: Awaited<ReturnType<typeof collectCpuMetrics>>;
memory: Awaited<ReturnType<typeof collectMemoryMetrics>>;
disk: Awaited<ReturnType<typeof collectDiskMetrics>>;
network: Awaited<ReturnType<typeof collectNetworkMetrics>>;
uptime: Awaited<ReturnType<typeof collectUptimeMetrics>>;
processes: Awaited<ReturnType<typeof collectProcessesMetrics>>;
system: Awaited<ReturnType<typeof collectSystemMetrics>>;
login_stats: Awaited<ReturnType<typeof collectLoginStats>>;
ports: PortsMetrics;
firewall: FirewallMetrics;
temperature: Awaited<ReturnType<typeof collectTemperatureMetrics>>;
gpu: GpuMetrics;
/** Results from other plugins' collectors, by collector id. */
extra?: Record<string, unknown>;
};
export interface CollectDeps {
ssh: PluginSsh;
state: MetricsState;
sessions: MetricsSessions;
collectors: CollectorRegistry;
}
async function optional<T>(collect: () => Promise<T>, fallback: T) {
try {
return await collect();
} catch {
return fallback;
}
}
/**
* One sample. Reuses a connection the person opened from the tab when there
* is one, otherwise borrows the "stats" pool, which answers password prompts
* only and fails fast on anything that needs a person.
*/
export async function collectMetrics(
deps: CollectDeps,
host: MetricsHost,
mounts: {
excludedMounts: string[];
monitoredMounts: Array<{ path: string; label?: string }>;
},
onAuthenticated?: () => void,
): Promise<CollectedMetrics> {
const { ssh, state, sessions, collectors } = deps;
if (!supportsMetrics(host, ssh)) {
throw new Error("Metrics collection only supported for SSH hosts");
}
if (state.authFailures.shouldSkip(host.id)) {
const reason = state.authFailures.getSkipReason(host.id);
throw new Error(reason || "Authentication failed");
}
const cached = state.metricsCache.get(host.id) as CollectedMetrics | null;
if (cached) {
onAuthenticated?.();
return cached;
}
return state.requestQueue.queueRequest(host.id, async () => {
const key = sessionKey(host.id, host.userId);
const existing = sessions.get(key);
try {
const collect = async (client: Client): Promise<CollectedMetrics> => {
onAuthenticated?.();
const platform = await detectHostPlatform(client);
const result: CollectedMetrics = {
cpu: await collectCpuMetrics(client, platform, host.id),
memory: await collectMemoryMetrics(client, platform),
disk: await collectDiskMetrics(
client,
mounts.excludedMounts,
mounts.monitoredMounts,
platform,
),
network: await collectNetworkMetrics(client, platform, host.id),
uptime: await collectUptimeMetrics(client, platform),
processes: await collectProcessesMetrics(client),
system: await collectSystemMetrics(client, platform),
login_stats: await optional(() => collectLoginStats(client), {
recentLogins: [],
failedLogins: [],
totalLogins: 0,
uniqueIPs: 0,
}),
ports: await optional(() => collectPortsMetrics(client), {
source: "none",
ports: [],
}),
firewall: await optional(() => collectFirewallMetrics(client), {
type: "none",
status: "unknown",
chains: [],
}),
temperature: await optional(() => collectTemperatureMetrics(client), {
source: "none",
highestCelsius: null,
sensors: [],
}),
gpu: await optional(() => collectGpuMetrics(client), {
source: "none",
gpus: [],
processes: [],
}),
};
const extra = await collectors.run(client, host.id, host.userId);
if (Object.keys(extra).length > 0) result.extra = extra;
state.metricsCache.set(host.id, result);
return result;
};
if (existing?.isConnected) {
existing.activeOperations++;
try {
const result = await collect(existing.client);
sessions.touch(key);
return result;
} finally {
existing.activeOperations--;
}
}
return await ssh.withConnection<CollectedMetrics, Client>(
host,
{
pool: "stats",
purpose: "metrics",
overrides: { readyTimeout: 60000 },
},
collect,
);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("TOTP authentication required")) {
throw error;
} else if (
error.message.includes("No password available") ||
error.message.includes("Unsupported authentication type") ||
error.message.includes("No SSH key available") ||
error.message.includes("Invalid SSH key format")
) {
state.authFailures.recordFailure(host.id, "AUTH", true);
} else if (isHostKeyVerificationError(error)) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
} else if (
error.message.includes("authentication") ||
error.message.includes("Permission denied") ||
error.message.includes("All configured authentication methods failed")
) {
state.authFailures.recordFailure(host.id, "AUTH");
} else if (
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT")
) {
state.authFailures.recordFailure(host.id, "TIMEOUT");
}
}
throw error;
}
});
}
@@ -0,0 +1,48 @@
import type { Client } from "ssh2";
import type { PluginServices } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
export const COLLECTORS_SERVICE = "host-metrics.collectors";
/**
* The "host-metrics.collectors" service, version 1. Another plugin provides
* it under its own provider name (manifest `provides[].names`) to add data
* to every sample. It runs on the sample's SSH connection, as the host's
* owner, and its result lands in the snapshot under `extra[<name>]`. A
* collector that throws is skipped for that sample.
*/
export interface MetricsCollectorV1 {
collect: (client: Client, hostId: number) => Promise<unknown>;
}
export class CollectorRegistry {
constructor(
private readonly services: PluginServices,
private readonly log: MetricsLogger,
) {}
async run(
client: Client,
hostId: number,
ownerUserId: string,
): Promise<Record<string, unknown>> {
const results: Record<string, unknown> = {};
for (const provider of this.services.providers(COLLECTORS_SERVICE)) {
if (!provider) continue;
try {
const collector = this.services.get<Partial<MetricsCollectorV1>>(
COLLECTORS_SERVICE,
{ provider, userId: ownerUserId },
);
if (typeof collector.collect !== "function") continue;
results[provider] = await collector.collect(client, hostId);
} catch (error) {
this.log.debug(`Metrics collector ${provider} failed`, {
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
}
+38 -47
View File
@@ -1,60 +1,51 @@
import type { Client } from "ssh2";
import { pluginSsh } from "./ssh.js";
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
export type StatsCapableHost = {
connectionType?: string;
authType?: string;
/** A host as ctx.ssh.resolveHost returns it, with the fields polling reads. */
export type MetricsHost = PluginSshHost & {
userId: string;
name?: string | null;
connectionType?: string | null;
enableSsh?: boolean | null;
password?: string | null;
sudoPassword?: string | null;
terminalConfig?: { sudoPassword?: string | null } | null;
enableDocker?: boolean | null;
jumpHosts?: Array<{ hostId: number }> | null;
};
export type TcpPingStatsConfig = {
statusCheckEnabled: boolean;
disableTcpPing?: boolean;
};
export function supportsMetrics(host: StatsCapableHost): boolean {
/** Metrics run over SSH, unattended, so the auth type has to allow that. */
export function supportsMetrics(
host: Pick<MetricsHost, "connectionType" | "enableSsh" | "authType">,
ssh: Pick<PluginSsh, "supportsBackground">,
): boolean {
const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh") return false;
return pluginSsh().supportsBackground(host.authType || "none");
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
return ssh.supportsBackground(host.authType || "none");
}
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
export function sudoPasswordOf(host: MetricsHost): string | undefined {
return host.sudoPassword || host.terminalConfig?.sudoPassword || undefined;
}
export function parseStatusHostIds(value: unknown): Set<number> | null {
if (value === undefined) return null;
if (typeof value !== "string") return new Set();
export type ConnectionLogType = "info" | "success" | "warning" | "error";
return new Set(
value
.split(",")
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0),
);
export interface ConnectionLog {
type: ConnectionLogType;
stage: string;
message: string;
details?: Record<string, unknown>;
}
export function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
/** The entry shape the connection log panel reads. */
export function connectionLog(
type: ConnectionLogType,
stage: string,
message: string,
details?: Record<string, unknown>,
): ConnectionLog {
return { type, stage, message, ...(details ? { details } : {}) };
}
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
});
});
export function newSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
@@ -1,131 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
export function registerHostMetricsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
): void {
/**
* @openapi
* /metrics/history/{id}:
* get:
* summary: Get historical metrics for a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of metric history rows.
* 403:
* description: Access denied.
* 404:
* description: Host not found or no access.
*/
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
// Normalize both sides to that format for reliable string comparison.
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await createCurrentHostMetricsHistoryRepository().listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({ rows, fromTs, toTs });
} catch (error) {
statsLogger.error("Failed to fetch metrics history", {
operation: "metrics_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch metrics history" });
}
});
}
@@ -0,0 +1,32 @@
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
/**
* Registered as ctx.registry.provide("host-metrics.hostImportNormalizer", ...)
* for core's bulk host import. A Termix export carries these settings under
* pluginSettings["host-metrics"]; exports from before 2.9.0 carried them in
* statsConfig, where the host's own interval only counted when
* useGlobalMetricsInterval was false.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const bag = raw.pluginSettings as
Record<string, Record<string, unknown>> | undefined;
const own = bag?.["host-metrics"];
if (own && typeof own === "object") {
const settings = readHostMetricsSettings(own);
return { ...settings };
}
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: null;
if (!legacy) return null;
const settings = readHostMetricsSettings({
...legacy,
metricsInterval:
legacy.useGlobalMetricsInterval === false ? legacy.metricsInterval : null,
});
return { ...settings };
}
+95 -21
View File
@@ -1,34 +1,108 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import {
onHostDeleted,
onHostLogin,
onHostUpdated,
shutdown,
startHostMetricsService,
} from "./routes.js";
import { createLogger } from "./log.js";
import { createHostMetricsRepository } from "./repository.js";
import { createMetricsState } from "./state.js";
import { MetricsSessions } from "./sessions.js";
import { CollectorRegistry } from "./collectors.js";
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
import { registerRoutes } from "./routes.js";
import { hostImportNormalizer } from "./host-import.js";
import { newSessionId, supportsMetrics } from "./helpers.js";
export type { MetricsCollectorV1 } from "./collectors.js";
export type { CollectionStatusPayload, SnapshotPayload } from "./poller.js";
/** The "host-metrics.viewers" service, version 1. Runs as the caller's user. */
export interface MetricsViewersV1 {
register: (
hostId: number,
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
heartbeat: (viewerSessionId: string) => boolean;
unregister: (hostId: number, viewerSessionId: string) => void;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
await startHostMetricsService(ctx.http.router());
const log = createLogger(ctx.log);
const repository = await createHostMetricsRepository(ctx.db);
const state = createMetricsState();
const sessions = new MetricsSessions(ctx.schedule, log);
const collectors = new CollectorRegistry(ctx.services, log);
const poller = new MetricsPoller({
ctx,
log,
repository,
ssh: ctx.ssh,
state,
sessions,
collectors,
});
ctx.disposables.add(() => {
poller.dispose();
sessions.dispose();
});
registerRoutes(ctx.http.router<Router>(), {
ctx,
log,
poller,
sessions,
repository,
onHealthCheck: (event) => ctx.events.emit(TOPIC_HEALTH_CHECK, event),
});
ctx.schedule.every(60_000, () => poller.cleanupInactiveViewers());
ctx.schedule.every(10 * 60_000, () => {
state.authFailures.cleanup();
state.backoff.cleanup();
});
// Core used to POST these to this plugin's own port with an internal auth
// token. They are events now: no port, no shared secret, and the
// subscriptions are torn down with the plugin.
ctx.events.on("host.updated", (payload) => {
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
const { hostId } = payload as { hostId?: number };
if (hostId) void poller.hostUpdated(hostId);
});
ctx.events.on("host.deleted", (payload) => {
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostDeleted(hostId);
});
ctx.events.on("host.login", (payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
ctx.events.on("host.status", (payload) => {
const { hostId, status } = payload as { hostId?: number; status?: string };
if (hostId && status) poller.hostStatusChanged(hostId, status);
});
ctx.events.on("host.key.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostKeyAccepted(hostId);
});
ctx.settings.onChange("metricsInterval", () => void poller.retimeAll());
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
register: async (hostId) => {
const userId = ctx.currentActor();
if (!userId) return { skipped: true, reason: "no_user" };
const host = await poller.resolve(hostId, userId);
if (!host) return { skipped: true, reason: "host_not_found" };
if (!supportsMetrics(host, ctx.ssh)) {
return { skipped: true, reason: "metrics_unsupported" };
}
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
return { skipped: true, reason: "metrics_disabled" };
}
const viewerSessionId = newSessionId("service");
poller.registerViewer(hostId, viewerSessionId, userId);
return { viewerSessionId };
},
heartbeat: (viewerSessionId) =>
poller.updateHeartbeat(viewerSessionId, ctx.currentActor()),
unregister: (hostId, viewerSessionId) =>
poller.unregisterViewer(hostId, viewerSessionId, ctx.currentActor()),
});
ctx.registry.provide(
"host-metrics.hostImportNormalizer",
hostImportNormalizer,
);
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
export async function deactivate() {
shutdown();
}
export async function deactivate() {}
@@ -0,0 +1,367 @@
import { Client, type ConnectConfig } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import {
connectionLog,
newSessionId,
type ConnectionLog,
type MetricsHost,
} from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
const CONNECT_TIMEOUT_MS = 60_000;
const TOTP_TTL_MS = 180_000;
const MAX_TOTP_ATTEMPTS = 3;
export type StartResult =
| { status: 200; body: Record<string, unknown> }
| { status: 400 | 401 | 500; body: Record<string, unknown> };
export interface InteractiveDeps {
ssh: PluginSsh;
sessions: MetricsSessions;
log: MetricsLogger;
registerViewer: (hostId: number, sessionId: string, userId: string) => void;
}
function stageForError(message: string): [string, string] {
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
return ["dns", `DNS resolution failed: ${message}`];
}
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
return ["tcp", `TCP connection failed: ${message}`];
}
if (message.includes("handshake") || message.includes("key exchange")) {
return ["handshake", `SSH handshake failed: ${message}`];
}
if (/authentication/i.test(message)) {
return ["auth", `Authentication failed: ${message}`];
}
if (message.includes("verification failed")) {
return [
"handshake",
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
];
}
return ["error", `SSH connection failed: ${message}`];
}
/**
* Opens a connection from the tab, where a person can answer a TOTP prompt,
* and keeps it for polling. Resolves with the HTTP answer.
*/
export async function startInteractive(
deps: InteractiveDeps,
host: MetricsHost,
userId: string,
): Promise<StartResult> {
const { ssh, sessions, log } = deps;
const logs: ConnectionLog[] = [
connectionLog("info", "stats_connecting", "Starting metrics collection"),
connectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
connectionLog("info", "handshake", "Initiating SSH handshake"),
];
if (host.authType === "password") {
logs.push(connectionLog("info", "auth", "Authenticating with password"));
} else if (host.authType === "key") {
logs.push(connectionLog("info", "auth", "Authenticating with SSH key"));
}
const key = sessionKey(host.id, userId);
if (sessions.get(key)?.isConnected) {
logs.push(
connectionLog(
"success",
"stats_polling",
"Using existing metrics session",
),
);
return { status: 200, body: { success: true, connectionLogs: logs } };
}
const client = new Client();
const prepared = await ssh.prepare(host, {
purpose: "metrics",
client,
log: (level, message) => logs.push(connectionLog(level, "auth", message)),
});
const config = prepared.config as ConnectConfig;
config.readyTimeout = CONNECT_TIMEOUT_MS;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
logs.push(connectionLog("error", "auth", outcome.message));
if (outcome.status === "interaction-required") {
return {
status: 401,
body: {
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs: logs,
},
};
}
return {
status: 400,
body: { error: outcome.message, connectionLogs: logs },
};
}
try {
const result = await new Promise<Record<string, unknown>>(
(resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(
() =>
settle(() => {
client.end();
reject(new Error("Connection timeout"));
}),
CONNECT_TIMEOUT_MS,
);
client.on(
"keyboard-interactive",
(name, instructions, _lang, prompts, finish) => {
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
if (decision.kind !== "totp") {
finish(ssh.autoResponses(prompts, host.password));
return;
}
const sessionId = newSessionId("totp");
sessions.addPending({
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId,
prompts: prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo ?? false,
})),
totpPromptIndex: decision.promptIndex,
resolvedPassword: host.password ?? undefined,
totpAttempts: 0,
});
logs.push(
connectionLog("info", "stats_totp", "TOTP verification required"),
);
settle(() =>
resolve({
success: false,
requires_totp: true,
sessionId,
prompt: prompts[decision.promptIndex].prompt,
}),
);
},
);
client.on("ready", () =>
settle(() => {
logs.push(
connectionLog(
"success",
"connected",
"SSH connection established successfully",
),
connectionLog(
"success",
"stats_polling",
"Metrics session established",
),
);
sessions.open(key, {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: host.id,
userId,
});
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(host.id, viewerSessionId, userId);
resolve({ success: true, viewerSessionId });
}),
);
client.on("error", (error) =>
settle(() => {
const message =
error instanceof Error ? error.message : String(error);
const [stage, text] = stageForError(message);
logs.push(connectionLog("error", stage, text));
log.error("SSH connection error in metrics/start", {
operation: "metrics_start_ssh_error",
hostId: host.id,
error: message,
});
reject(error);
}),
);
if (Array.isArray(host.jumpHosts) && host.jumpHosts.length > 0) {
logs.push(
connectionLog("info", "proxy", "Connecting via jump host chain"),
);
}
ssh
.openTransport(host, config as Record<string, unknown>)
.then((transport) => {
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as Client;
client.on("close", () => jumpClient.end());
}
client.connect(config);
})
.catch((error) =>
settle(() => {
logs.push(
connectionLog(
"error",
"proxy",
`Connection setup failed: ${error instanceof Error ? error.message : String(error)}`,
),
);
reject(error);
}),
);
},
);
return { status: 200, body: { ...result, connectionLogs: logs } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logs.push(
connectionLog(
"error",
"stats_connecting",
`Failed to start metrics: ${message}`,
),
);
return {
status: 500,
body: {
error: message || "Failed to start metrics collection",
connectionLogs: logs,
},
};
}
}
export type TotpResult = { status: number; body: Record<string, unknown> };
/** Sends the TOTP code a person typed and keeps the connection on success. */
export async function submitTotp(
deps: InteractiveDeps,
userId: string,
sessionId: string,
totpCode: string,
): Promise<TotpResult> {
const { sessions, log } = deps;
const session = sessions.getPending(sessionId);
if (!session) {
return {
status: 404,
body: { error: "TOTP session not found or expired" },
};
}
if (Date.now() - session.createdAt > TOTP_TTL_MS) {
sessions.dropPending(sessionId, true);
return { status: 408, body: { error: "TOTP session timeout" } };
}
if (session.userId !== userId) {
return { status: 403, body: { error: "Unauthorized" } };
}
session.totpAttempts++;
if (session.totpAttempts > MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
return { status: 429, body: { error: "Too many TOTP attempts" } };
}
const passwordFor = (prompt: string) =>
/password/i.test(prompt) && session.resolvedPassword
? session.resolvedPassword
: "";
try {
const responses = (session.prompts || []).map((p, index) =>
index === session.totpPromptIndex
? totpCode.trim()
: passwordFor(p.prompt),
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("TOTP verification timeout")),
30_000,
);
session.client.once(
"keyboard-interactive",
(_name, _instructions, _lang, prompts, finish) => {
log.warn("Second keyboard-interactive received after TOTP", {
operation: "totp_second_keyboard_interactive",
hostId: session.hostId,
});
finish(prompts.map((p) => passwordFor(p.prompt)));
},
);
session.client.once("ready", () => {
clearTimeout(timeout);
resolve();
});
session.client.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
session.finish(responses);
await ready;
sessions.open(sessionKey(session.hostId, userId), {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: session.hostId,
userId,
});
sessions.dropPending(sessionId);
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(session.hostId, viewerSessionId, userId);
return { status: 200, body: { success: true, viewerSessionId } };
} catch (error) {
log.error("TOTP verification failed", {
operation: "totp_verification_failed",
hostId: session.hostId,
error: error instanceof Error ? error.message : String(error),
});
if (session.totpAttempts >= MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
}
return {
status: 401,
body: {
error: "TOTP verification failed",
attemptsRemaining: Math.max(
0,
MAX_TOTP_ATTEMPTS - session.totpAttempts,
),
},
};
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type Meta = Record<string, unknown>;
export interface MetricsLogger {
debug: (message: string, meta?: Meta) => void;
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
/** An error, or structured context, or both. */
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
return meta && Object.keys(meta).length > 0
? `${message} ${JSON.stringify(meta)}`
: message;
}
/** ctx.log with the structured context the old stats logger carried. */
export function createLogger(log: PluginLogger): MetricsLogger {
return {
debug: (message, meta) => log.debug(withMeta(message, meta)),
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, errorOrMeta, meta) => {
if (errorOrMeta instanceof Error) {
log.error(withMeta(message, meta), errorOrMeta);
return;
}
log.error(
withMeta(message, {
...((errorOrMeta as Meta | undefined) ?? {}),
...meta,
}),
);
},
};
}
@@ -1,7 +1,9 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
@@ -78,14 +80,12 @@ export function buildApplyCrontabCommand(body: string): string {
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
managerHandler(deps, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
@@ -95,7 +95,7 @@ export function registerCronRoutes(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"cron_replace",
async (client, _host, req) => {
@@ -1,15 +1,14 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
@@ -45,13 +44,14 @@ export function buildNftRuleCommand(
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
managerHandler(deps, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
@@ -60,7 +60,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_rule",
async (client, host, req) => {
@@ -105,7 +105,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_persist",
async (client, host) => {
@@ -1,13 +1,13 @@
import type { Express } from "express";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { isValidPort } from "./validation.js";
import type { Router } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
import type { HostMetricsRepository } from "../repository.js";
export interface HealthCheck {
id: string;
@@ -111,24 +111,12 @@ async function runChecks(
const HISTORY_KEEP = 500;
function recordHistory(
userId: string,
hostId: number,
results: HealthResult[],
): Promise<void> {
return createCurrentHostHealthRepository()
.recordHistory(userId, hostId, results, HISTORY_KEEP)
.then(() => undefined);
}
async function loadChecks(
repository: HostMetricsRepository,
userId: string,
hostId: number,
): Promise<HealthCheck[]> {
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
userId,
hostId,
);
const row = await repository.findChecks(userId, hostId);
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
@@ -139,60 +127,50 @@ async function loadChecks(
}
export function registerHealthRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, repository } = deps;
const record = async (
userId: string,
hostId: number,
results: HealthResult[],
) => {
if (!results.length) return;
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
for (const result of results) {
deps.onHealthCheck({
hostId,
userId,
checkId: result.checkId,
ok: result.ok,
detail: result.detail ?? undefined,
});
}
};
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
const history = (
await createCurrentHostHealthRepository().listHistory(
userId,
host.id,
200,
)
).map((row) => ({
checkId: row.checkId,
ts: row.ts,
ok: row.ok,
latencyMs: row.latencyMs,
detail: row.detail,
}));
return { checks, results, history };
},
),
managerHandler(deps, "connect", "health_get", async (client, host) => {
const userId = host.actorId;
const checks = await loadChecks(repository, userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
await record(userId, host.id, results);
const history = await repository.listHealth(userId, host.id, 200);
return { checks, results, history };
}),
);
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const userId = host.actorId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
@@ -206,13 +184,11 @@ export function registerHealthRoutes(
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
const now = new Date().toISOString();
await createCurrentHostHealthRepository().upsertChecks(
await repository.saveChecks(
userId,
host.id,
JSON.stringify(checks),
interval,
now,
);
return { success: true };
},
@@ -222,28 +198,11 @@ export function registerHealthRoutes(
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
return { results };
},
),
managerHandler(deps, "connect", "health_run", async (client, host) => {
const checks = await loadChecks(repository, host.actorId, host.id);
const results = await runChecks(client, checks);
await record(host.actorId, host.id, results);
return { results };
}),
);
}
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
@@ -19,10 +19,10 @@ import { registerWireGuardRoutes } from "./wireguard.js";
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Express,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, runOnHost } = deps;
const { validateHostId } = deps;
/**
* @openapi
@@ -44,7 +44,7 @@ export function registerManagerRoutes(
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
managerHandler(deps, "connect", "platform_detect", (client) =>
detectPlatform(client),
),
);
@@ -1,14 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "@termix/plugin-sdk/host-commands";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isAllowedPath,
isValidSystemdUnit,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
@@ -44,14 +41,12 @@ export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
managerHandler(deps, "connect", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
@@ -65,33 +60,28 @@ export function registerLogRoutes(
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
},
),
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
}),
);
}
@@ -1,25 +1,26 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import {
execCommand,
execElevated,
detectPlatform,
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
} from "@termix/plugin-sdk/host-commands";
import { isValidPackageName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
managerHandler(deps, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
@@ -35,7 +36,7 @@ export function registerPackageRoutes(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"packages_action",
async (client, host, req) => {
@@ -1,12 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPid,
isValidSignal,
type Signal,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
@@ -54,9 +49,10 @@ export function buildKillCommand(pid: number, signal: Signal): string {
}
export function registerProcessRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/processes/{id}:
@@ -74,7 +70,7 @@ export function registerProcessRoutes(
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
managerHandler(deps, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
@@ -109,7 +105,7 @@ export function registerProcessRoutes(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"processes_signal",
async (client, host, req) => {
@@ -1,11 +1,10 @@
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
import { ElevationError } from "@termix/plugin-sdk/host-commands";
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { statsLogger } from "../../../../../src/backend/utils/logger.js";
import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import { errorMessage } from "../util.js";
import type { MetricsLogger } from "../log.js";
import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -26,16 +25,15 @@ export class ManagerInputError extends Error {
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
runOnHost: RunOnHost,
level: HostAction,
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
level: PluginHostShareLevel,
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, userId, level, (client, host) =>
const result = await runOnHost(hostId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
@@ -49,13 +47,13 @@ export function managerHandler(
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
statsLogger.error(`Manager operation failed: ${operation}`, {
log.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: getErrorMessage(error, "Operation failed"),
error: errorMessage(error, "Operation failed"),
});
}
};
@@ -1,12 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
@@ -50,9 +49,10 @@ export function buildServiceActionCommand(
}
export function registerServiceRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/services/{id}:
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "connect", "services_list", async (client) => {
managerHandler(deps, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
@@ -105,7 +105,7 @@ export function registerServiceRoutes(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"services_action",
async (client, host, req) => {
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
@@ -106,13 +106,14 @@ export function parseDfMounts(output: string): MountUsage[] {
}
export function registerSimpleReadRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
managerHandler(deps, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
@@ -121,7 +122,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
managerHandler(deps, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
@@ -130,7 +131,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
+87 -104
View File
@@ -1,16 +1,16 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
detectPlatform,
} from "@termix/plugin-sdk/host-commands";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
@@ -149,14 +149,12 @@ export function buildRevokeCommand(client: AcmeClient, name: string): string {
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
@@ -188,78 +186,68 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
/**
@@ -290,31 +278,26 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
}
@@ -1,28 +1,43 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import type { HostMetricsRepository } from "../repository.js";
import type { MetricsLogger } from "../log.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
/** The user making the request, who may not own the host. */
actorId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* user has at least `level` access. Resolves the host (with sudoPassword) so
* managers can elevate. Rejects with an access error if not permitted.
* acting user has at least `level` access. Resolves the host (with
* sudoPassword) so managers can elevate. Rejects with an access error if not
* permitted.
*/
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: HostAction,
level: PluginHostShareLevel,
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface HealthCheckEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
log: MetricsLogger;
repository: HostMetricsRepository;
onHealthCheck: (event: HealthCheckEvent) => void;
}
@@ -1,11 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidUsername,
isValidGroupName,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
@@ -75,14 +71,12 @@ export function parseSudoers(output: string): string[] {
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "connect", "users_list", async (client) => {
managerHandler(deps, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
@@ -100,7 +94,7 @@ export function registerUserRoutes(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"users_action",
async (client, host, req) => {
@@ -0,0 +1,127 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
@@ -1,11 +1,11 @@
import type { Express } from "express";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface WireGuardPeer {
publicKey: string;
@@ -117,9 +117,10 @@ export function parseWireGuardData(output: string): WireGuardData {
}
export function registerWireGuardRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
@@ -140,23 +141,13 @@ export function registerWireGuardRoutes(
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
client,
PROBE_CMD,
host.sudoPassword,
{
forceSudo: false,
timeoutMs: 15000,
},
);
return parseWireGuardData(result.stdout);
},
),
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
forceSudo: false,
timeoutMs: 15000,
});
return parseWireGuardData(result.stdout);
}),
);
/**
@@ -192,7 +183,7 @@ export function registerWireGuardRoutes(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"wireguard_action",
async (client, host, req) => {
+469
View File
@@ -0,0 +1,469 @@
import type {
PluginContext,
PluginHostStatusEntry,
} from "@termix/plugin-sdk/backend";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
collectMetrics,
isHostKeyVerificationError,
type CollectDeps,
type CollectedMetrics,
} from "./collect.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import type { HostMetricsRepository } from "./repository.js";
import type { MetricsViewer } from "./sessions.js";
import {
canStartInitialMetrics,
metricsConcurrencyFor,
type MetricsState,
} from "./state.js";
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
import { clearNetworkSampleCache } from "./widgets/network-collector.js";
export const DEFAULT_METRICS_INTERVAL = 30;
export const DEFAULT_RETENTION_DAYS = 7;
/** Viewers that stop sending heartbeats are dropped after this long. */
const VIEWER_TIMEOUT_MS = 120_000;
export const TOPIC_SNAPSHOT = "plugin.host-metrics.snapshot";
export const TOPIC_STATUS = "plugin.host-metrics.status";
export const TOPIC_HEALTH_CHECK = "plugin.host-metrics.health-check";
/** Where collection stands for a host, sent when it changes. */
export type CollectionState =
"collecting" | "auth_failed" | "host_key_changed" | "unreachable";
export interface SnapshotPayload {
hostId: number;
ownerUserId: string;
metrics: CollectedMetrics;
}
export interface CollectionStatusPayload {
hostId: number;
ownerUserId: string;
state: CollectionState;
previous: CollectionState | null;
}
interface Polled {
host: MetricsHost;
viewerUserId: string;
stop: () => void;
}
export interface PollerDeps extends CollectDeps {
ctx: PluginContext;
log: MetricsLogger;
repository: HostMetricsRepository;
}
/**
* Heavy SSH metrics for hosts somebody is looking at. Polling starts with the
* first viewer and stops with the last one; a viewer is a tab, a dashboard
* card, or another plugin through the "host-metrics.viewers" service.
*/
export class MetricsPoller {
private readonly polled = new Map<number, Polled>();
private readonly metrics = new Map<
number,
{ data: CollectedMetrics; timestamp: number }
>();
private readonly viewers = new Map<number, Set<string>>();
private readonly viewerDetails = new Map<string, MetricsViewer>();
private readonly inFlight = new Set<number>();
private readonly initialRequested = new Set<number>();
private readonly collectionState = new Map<number, CollectionState>();
constructor(private readonly deps: PollerDeps) {}
private get ctx() {
return this.deps.ctx;
}
private get state(): MetricsState {
return this.deps.state;
}
getMetrics(hostId: number) {
return this.metrics.get(hostId);
}
isPolling(hostId: number): boolean {
return this.polled.has(hostId);
}
hasViewers(hostId: number): boolean {
return (this.viewers.get(hostId)?.size ?? 0) > 0;
}
registerViewer(hostId: number, sessionId: string, userId: string): void {
let set = this.viewers.get(hostId);
if (!set) {
set = new Set();
this.viewers.set(hostId, set);
}
set.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (set.size === 1) {
// Never let a failed start reach the request that registered it.
void this.start(hostId, userId).catch((error) => {
this.deps.log.warn("Could not start metrics polling", {
operation: "start_metrics_unhandled",
hostId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
/** False when the viewer is gone, so the caller registers again. */
updateHeartbeat(sessionId: string, userId?: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (!viewer || (userId && viewer.userId !== userId)) return false;
viewer.lastHeartbeat = Date.now();
return true;
}
unregisterViewer(hostId: number, sessionId: string, userId?: string): void {
const viewer = this.viewerDetails.get(sessionId);
if (viewer && userId && viewer.userId !== userId) return;
const set = this.viewers.get(hostId);
if (set) {
set.delete(sessionId);
if (set.size === 0) {
this.viewers.delete(hostId);
this.stop(hostId, false);
}
}
this.viewerDetails.delete(sessionId);
}
/** Stops a host's polling without dropping its viewers. */
pause(hostId: number): void {
this.stop(hostId, false);
}
cleanupInactiveViewers(now = Date.now()): void {
for (const [sessionId, viewer] of [...this.viewerDetails.entries()]) {
if (now - viewer.lastHeartbeat > VIEWER_TIMEOUT_MS) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
/** A host's details changed: drop what was cached and start over. */
async hostUpdated(hostId: number): Promise<void> {
this.state.hostCache.invalidate(hostId);
this.state.metricsCache.clear(hostId);
this.ctx.ssh.dropPooled("stats", hostId);
const polled = this.polled.get(hostId);
if (polled) await this.start(hostId, polled.viewerUserId);
}
hostDeleted(hostId: number): void {
this.stop(hostId, true);
for (const sessionId of this.viewers.get(hostId) ?? []) {
this.viewerDetails.delete(sessionId);
}
this.viewers.delete(hostId);
this.collectionState.delete(hostId);
this.state.authFailures.reset(hostId);
this.state.backoff.reset(hostId);
}
/** Core's status check says the host answers again. */
hostStatusChanged(hostId: number, status: string): void {
if (status === "offline") return;
const polled = this.polled.get(hostId);
if (polled) this.scheduleInitialPoll(polled.host);
}
hostKeyAccepted(hostId: number): void {
this.state.authFailures.resetHostKeyFailure(hostId);
}
/** Re-times every running host, after the admin interval changed. */
async retimeAll(): Promise<void> {
for (const [hostId, polled] of [...this.polled.entries()]) {
await this.start(hostId, polled.viewerUserId);
}
}
dispose(): void {
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
this.viewers.clear();
this.viewerDetails.clear();
clearCpuSampleCache();
clearNetworkSampleCache();
}
/** The host as `userId` may reach it, cached for a few minutes. */
async resolve(hostId: number, userId: string): Promise<MetricsHost | null> {
const cached = this.state.hostCache.get(
hostId,
userId,
) as MetricsHost | null;
if (cached) return cached;
const host = (await this.ctx.asUser(userId, () =>
this.ctx.ssh.resolveHost(hostId),
)) as MetricsHost | null;
if (!host) return null;
this.state.hostCache.set(hostId, userId, host);
return host;
}
async settingsFor(hostId: number) {
const settings = readHostMetricsSettings(
await this.ctx.settings.getAll("host", hostId),
);
const adminInterval = Number(
await this.ctx.settings.get<number>("metricsInterval"),
);
return {
...settings,
intervalSeconds:
settings.metricsInterval ??
(Number.isInteger(adminInterval) && adminInterval >= 5
? adminInterval
: DEFAULT_METRICS_INTERVAL),
};
}
private async start(hostId: number, userId: string): Promise<void> {
const host = await this.resolve(hostId, userId);
this.stop(hostId, false);
if (!host || !supportsMetrics(host, this.ctx.ssh)) return;
const settings = await this.settingsFor(hostId);
if (!settings.metricsEnabled) {
this.metrics.delete(hostId);
return;
}
const intervalMs = settings.intervalSeconds * 1000;
const stop = this.ctx.schedule.every(
intervalMs,
() => {
const latest = this.polled.get(hostId);
if (latest) this.schedulePoll(latest.host);
},
// Spread timers so a fleet does not fire on the same second.
{ jitterMs: Math.min(intervalMs * 0.2, 15_000) },
);
this.polled.set(hostId, { host, viewerUserId: userId, stop });
this.syncConcurrency();
this.scheduleInitialPoll(host);
}
private stop(hostId: number, clearData: boolean): void {
const polled = this.polled.get(hostId);
if (polled) {
polled.stop();
this.polled.delete(hostId);
this.syncConcurrency();
}
this.inFlight.delete(hostId);
if (clearData) {
this.metrics.delete(hostId);
this.state.hostCache.invalidate(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
}
}
/** Keeps poll concurrency matched to how many hosts are being polled. */
private syncConcurrency(): void {
const target = metricsConcurrencyFor(this.polled.size);
const limiter = this.state.metricsLimiter;
if (target === limiter.limit) return;
const previous = limiter.limit;
limiter.setLimit(target);
this.deps.log.info(
`Metrics poll concurrency ${previous} -> ${target} for ${this.polled.size} host(s)`,
{ operation: "metrics_concurrency_resize" },
);
}
/**
* The first heavy sample waits for core's cheap status check to say the
* host answers, so a whole fleet registering at once does not open a
* burst of SSH connections to hosts that are down.
*/
private scheduleInitialPoll(host: MetricsHost): void {
if (this.metrics.has(host.id) || this.initialRequested.has(host.id)) {
return;
}
this.initialRequested.add(host.id);
void this.state.initialLimiter
.run(async () => {
let status: PluginHostStatusEntry | null = this.ctx.hosts.status.get(
host.id,
);
if (!status) status = await this.ctx.hosts.status.check(host.id);
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
return;
}
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await this.state.metricsLimiter.run(() => this.poll(host));
} finally {
this.inFlight.delete(host.id);
}
})
.catch((error) => {
this.deps.log.error("Initial metrics polling failed", error, {
operation: "initial_metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.initialRequested.delete(host.id));
}
private schedulePoll(host: MetricsHost): void {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
void this.state.metricsLimiter
.run(() => this.poll(host))
.catch((error) => {
this.deps.log.error("Metrics polling failed", error, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.inFlight.delete(host.id));
}
/** One sample for one host. Public for tests. */
async poll(host: MetricsHost): Promise<void> {
const { state, log } = this.deps;
if (!this.polled.has(host.id)) return;
if (state.authFailures.shouldSkip(host.id)) return;
if (state.backoff.shouldSkip(host.id)) return;
const settings = await this.settingsFor(host.id);
if (!settings.metricsEnabled) return;
let authenticated = false;
try {
const metrics = await collectMetrics(this.deps, host, settings, () => {
authenticated = true;
});
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
this.ctx.hosts.status.reportLogin(host.id, { ok: true });
this.setCollectionState(host, "collecting");
await this.recordSample(host.id, metrics);
this.ctx.events.emit(TOPIC_SNAPSHOT, {
hostId: host.id,
ownerUserId: host.userId,
metrics,
} satisfies SnapshotPayload);
state.backoff.reset(host.id);
state.authFailures.reset(host.id);
} catch (error) {
const hostKeyChanged = isHostKeyVerificationError(error);
const message = error instanceof Error ? error.message : "";
const isAuthError = /authentication|permission denied/i.test(message);
if (!authenticated) {
this.ctx.hosts.status.reportLogin(host.id, {
ok: false,
hostKeyChanged,
});
}
this.setCollectionState(
host,
hostKeyChanged
? "host_key_changed"
: isAuthError
? "auth_failed"
: "unreachable",
);
if (isAuthError) {
// collectMetrics already recorded it; log the first occurrence only.
if (!state.authFailures.shouldSkip(host.id)) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
return;
}
if (hostKeyChanged) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
log.error("Stats collector host key verification failed", error, {
operation: "stats_host_key_verification_failed",
hostId: host.id,
});
return;
}
state.backoff.recordFailure(host.id);
// Only log when a new retry window opens, not on every skipped poll.
const backoff = state.backoff.getBackoffInfo(host.id);
if (backoff !== null && !backoff.includes("polling suspended")) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
}
}
private setCollectionState(host: MetricsHost, next: CollectionState): void {
const previous = this.collectionState.get(host.id) ?? null;
if (previous === next) return;
this.collectionState.set(host.id, next);
this.ctx.events.emit(TOPIC_STATUS, {
hostId: host.id,
ownerUserId: host.userId,
state: next,
previous,
} satisfies CollectionStatusPayload);
}
private async recordSample(
hostId: number,
metrics: CollectedMetrics,
): Promise<void> {
try {
const iface = metrics.network?.interfaces?.[0];
const bytes = (value: string | null | undefined) => {
const parsed = value ? parseInt(value, 10) : NaN;
return Number.isNaN(parsed) ? null : parsed;
};
const retention = Number(
await this.ctx.settings.get<number>("historyRetentionDays"),
);
await this.deps.repository.recordSample(
{
hostId,
cpuPercent: metrics.cpu?.percent ?? null,
memPercent: metrics.memory?.percent ?? null,
diskPercent: metrics.disk?.percent ?? null,
netRxBytes: bytes(iface?.rxBytes),
netTxBytes: bytes(iface?.txBytes),
},
Number.isInteger(retention) && retention >= 1
? Math.min(retention, 90)
: DEFAULT_RETENTION_DAYS,
);
} catch (error) {
this.deps.log.warn("Failed to write metrics history", {
operation: "insert_metrics_history",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -1,230 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../../../src/types/host-metrics.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
}
interface PrefHost {
id: number;
userId: string;
statsConfig?: unknown;
}
type HostMetricsPreferencesRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
export function registerHostMetricsPreferencesRoutes(
app: Express,
{
validateHostId,
fetchHostById,
parseStatsConfig,
canAccessHost,
}: HostMetricsPreferencesRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
* 404:
* description: Host not found.
*/
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const row =
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
userId,
hostId,
);
if (row?.layout) {
try {
const parsed = sanitizeLayout(JSON.parse(row.layout));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to default
}
}
const statsConfig = parseStatsConfig(host.statsConfig);
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
return res.json({ layout });
} catch (error) {
statsLogger.error("Failed to fetch host metrics preferences", {
operation: "host_metrics_prefs_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
});
/**
* @openapi
* /host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
app.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const layout = sanitizeLayout(req.body);
if (!layout) {
return res.status(400).json({ error: "Invalid layout" });
}
const now = new Date().toISOString();
const layoutJson = JSON.stringify(layout);
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
userId,
hostId,
layoutJson,
now,
);
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
// user owns. stats_config is plain JSON text (not an encrypted field).
if (host.userId === userId) {
try {
const current = parseStatsConfig(host.statsConfig);
const merged = {
...current,
enabledWidgets: deriveEnabledWidgets(layout.slots),
};
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
userId,
hostId,
JSON.stringify(merged),
);
} catch (syncErr) {
statsLogger.warn("Failed to sync enabledWidgets from layout", {
operation: "host_metrics_prefs_sync_widgets",
hostId,
error:
syncErr instanceof Error ? syncErr.message : String(syncErr),
});
}
}
return res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save host metrics preferences", {
operation: "host_metrics_prefs_save_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
}
@@ -0,0 +1,233 @@
import { and, asc, desc, eq, gte, lt, lte, notInArray } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import {
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
hostMetricsPreferences,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands tables back untyped, and the drizzle handle is the
// server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface HealthResultInput {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
export interface HealthHistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
detail: string | null;
}
export interface MetricsHistoryInput {
hostId: number;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
export interface MetricsHistoryRow {
ts: string;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
/** Timestamps are text in `YYYY-MM-DD HH:MM:SS` UTC, which sorts in time order. */
export function sqlTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
export type HostMetricsRepository = Awaited<
ReturnType<typeof createHostMetricsRepository>
>;
/**
* Written without RETURNING so it runs the same on all three engines: an
* upsert reads the row by its unique (user, host) pair first.
*/
export async function createHostMetricsRepository(db: PluginDatabase) {
const preferences: Table = await db.define(hostMetricsPreferences);
const checks: Table = await db.define(hostHealthChecks);
const health: Table = await db.define(hostHealthHistory);
const history: Table = await db.define(hostMetricsHistory);
const client = () => db.client<Drizzle>();
async function findOne(table: Table, userId: string, hostId: number) {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.limit(1);
return rows[0] ?? null;
}
async function upsert(
table: Table,
userId: string,
hostId: number,
values: Record<string, unknown>,
) {
const drizzle = await client();
const now = sqlTimestamp(new Date());
const existing = await findOne(table, userId, hostId);
if (existing) {
await drizzle
.update(table)
.set({ ...values, updatedAt: now })
.where(eq(table.id, existing.id));
} else {
await drizzle.insert(table).values({
...values,
userId,
hostId,
createdAt: now,
updatedAt: now,
});
}
await db.persist();
}
return {
async findLayout(userId: string, hostId: number): Promise<string | null> {
return (await findOne(preferences, userId, hostId))?.layout ?? null;
},
saveLayout(userId: string, hostId: number, layout: string) {
return upsert(preferences, userId, hostId, { layout });
},
async findChecks(
userId: string,
hostId: number,
): Promise<{ checks: string; intervalSeconds: number } | null> {
return findOne(checks, userId, hostId);
},
saveChecks(
userId: string,
hostId: number,
json: string,
intervalSeconds: number,
) {
return upsert(checks, userId, hostId, { checks: json, intervalSeconds });
},
/** Adds results and keeps only the newest `keep` rows for the host. */
async recordHealth(
userId: string,
hostId: number,
results: HealthResultInput[],
keep: number,
): Promise<void> {
if (results.length === 0) return;
const drizzle = await client();
const ts = sqlTimestamp(new Date());
await drizzle
.insert(health)
.values(results.map((result) => ({ ...result, userId, hostId, ts })));
const scope = and(eq(health.userId, userId), eq(health.hostId, hostId));
const retained = await drizzle
.select({ id: health.id })
.from(health)
.where(scope)
.orderBy(desc(health.ts), desc(health.id))
.limit(keep);
await drizzle.delete(health).where(
retained.length
? and(
scope,
notInArray(
health.id,
retained.map((row: { id: number }) => row.id),
),
)
: scope,
);
await db.persist();
},
async listHealth(
userId: string,
hostId: number,
limit: number,
): Promise<HealthHistoryRow[]> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(health)
.where(and(eq(health.userId, userId), eq(health.hostId, hostId)))
.orderBy(desc(health.ts), desc(health.id))
.limit(limit);
return rows.map((row: Record<string, unknown>) => ({
checkId: row.checkId as string,
ts: row.ts as string,
ok: row.ok === true || row.ok === 1,
latencyMs: (row.latencyMs as number | null) ?? null,
detail: (row.detail as string | null) ?? null,
}));
},
/** One sample, then drops the host's samples older than the retention. */
async recordSample(
input: MetricsHistoryInput,
retentionDays: number,
): Promise<void> {
const drizzle = await client();
const now = new Date();
await drizzle.insert(history).values({ ...input, ts: sqlTimestamp(now) });
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
await drizzle
.delete(history)
.where(
and(
eq(history.hostId, input.hostId),
lt(history.ts, sqlTimestamp(cutoff)),
),
);
// A sample every few seconds per host: let the debounced save pick it up.
await db.persist({ lazy: true });
},
async listSamples(
hostId: number,
fromTs: string,
toTs: string,
): Promise<MetricsHistoryRow[]> {
const drizzle = await client();
return drizzle
.select({
ts: history.ts,
cpuPercent: history.cpuPercent,
memPercent: history.memPercent,
diskPercent: history.diskPercent,
netRxBytes: history.netRxBytes,
netTxBytes: history.netTxBytes,
})
.from(history)
.where(
and(
eq(history.hostId, hostId),
gte(history.ts, fromTs),
lte(history.ts, toTs),
),
)
.orderBy(asc(history.ts));
},
};
}
File diff suppressed because it is too large. Load diff
+105 -45
View File
@@ -1,16 +1,19 @@
import type { Client, ConnectConfig } from "ssh2";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
/** A connection a person opened from the tab, reused by polling. */
export interface MetricsSession {
client: Client;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId: number;
userId: string;
cancelCleanup?: () => void;
}
/** A connection waiting for the TOTP code the person is typing. */
export interface PendingTOTPSession {
client: Client;
finish: (responses: string[]) => void;
@@ -32,49 +35,106 @@ export interface MetricsViewer {
lastHeartbeat: number;
}
export const metricsSessions: Record<string, MetricsSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const IDLE_MS = 30 * 60 * 1000;
export function cleanupMetricsSession(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
statsLogger.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleMetricsSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete metricsSessions[sessionId];
}
}
export function scheduleMetricsSessionCleanup(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => {
cleanupMetricsSession(sessionId);
},
30 * 60 * 1000,
);
}
}
export function getSessionKey(hostId: number, userId: string): string {
export function sessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
function endQuietly(client: Client): void {
try {
client.end();
} catch {
// expected
}
}
/** Interactive connections, owned by one activation of the plugin. */
export class MetricsSessions {
private readonly sessions = new Map<string, MetricsSession>();
private readonly pending = new Map<string, PendingTOTPSession>();
constructor(
private readonly schedule: PluginSchedule,
private readonly log: MetricsLogger,
) {}
get(key: string): MetricsSession | undefined {
return this.sessions.get(key);
}
/** Stores a ready connection and closes it after 30 idle minutes. */
open(key: string, session: Omit<MetricsSession, "cancelCleanup">): void {
const existing = this.sessions.get(key);
if (existing && existing.client !== session.client) this.close(key);
this.sessions.set(key, { ...session });
this.scheduleCleanup(key);
session.client.once("close", () => {
const current = this.sessions.get(key);
if (current?.client === session.client) {
current.isConnected = false;
current.cancelCleanup?.();
this.sessions.delete(key);
}
});
}
touch(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.lastActive = Date.now();
this.scheduleCleanup(key);
}
/** Closes a session, or waits for the operations still using it. */
close(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
if (session.activeOperations > 0) {
this.log.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{ operation: "cleanup_deferred", sessionKey: key },
);
this.scheduleCleanup(key);
return;
}
session.cancelCleanup?.();
this.sessions.delete(key);
endQuietly(session.client);
}
addPending(session: PendingTOTPSession): void {
this.pending.set(session.sessionId, session);
}
getPending(sessionId: string): PendingTOTPSession | undefined {
return this.pending.get(sessionId);
}
dropPending(sessionId: string, endClient = false): void {
const session = this.pending.get(sessionId);
this.pending.delete(sessionId);
if (session && endClient) endQuietly(session.client);
}
dispose(): void {
for (const key of [...this.sessions.keys()]) {
const session = this.sessions.get(key)!;
session.cancelCleanup?.();
endQuietly(session.client);
}
this.sessions.clear();
for (const session of this.pending.values()) endQuietly(session.client);
this.pending.clear();
}
private scheduleCleanup(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.cancelCleanup?.();
session.cancelCleanup = this.schedule.after(IDLE_MS, () => {
session.cancelCleanup = undefined;
this.close(key);
});
}
}
@@ -1,256 +0,0 @@
import type { Express, RequestHandler } from "express";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
metricsInterval: number;
};
type HostMetricsSettingsRoutesDeps = {
requireAdmin: RequestHandler;
defaultStatsConfig: HostMetricsSettingsConfig;
refreshAllPolling: () => Promise<void>;
};
function isMissingSettingsTable(error: unknown): boolean {
return (
error instanceof Error && error.message.includes("no such table: settings")
);
}
export function registerHostMetricsSettingsRoutes(
app: Express,
{
requireAdmin,
defaultStatsConfig: DEFAULT_STATS_CONFIG,
refreshAllPolling,
}: HostMetricsSettingsRoutesDeps,
): void {
/**
* @openapi
* /global-settings:
* get:
* summary: Get global monitoring defaults
* tags:
* - Host Metrics
* responses:
* 200:
* description: Global monitoring settings.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings", requireAdmin, async (_req, res) => {
try {
const settings = createCurrentSettingsRepository();
const statusValue = await settings.get("global_status_check_interval");
const metricsValue = await settings.get("global_metrics_interval");
res.json({
statusCheckInterval: statusValue
? parseInt(statusValue, 10)
: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: metricsValue
? parseInt(metricsValue, 10)
: DEFAULT_STATS_CONFIG.metricsInterval,
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.warn("Settings table does not exist, using defaults", {
operation: "global_settings_table_check",
error: error.message,
});
return res.json({
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
});
}
statsLogger.error("Failed to fetch global settings", {
operation: "global_settings_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch global settings" });
}
});
/**
* @openapi
* /global-settings:
* post:
* summary: Update global monitoring defaults
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* metricsInterval:
* type: integer
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid parameters.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings", requireAdmin, async (req, res) => {
const { statusCheckInterval, metricsInterval } = req.body;
if (
statusCheckInterval !== undefined &&
(typeof statusCheckInterval !== "number" ||
statusCheckInterval < 5 ||
statusCheckInterval > 3600)
) {
return res.status(400).json({
error: "statusCheckInterval must be between 5 and 3600 seconds",
});
}
if (
metricsInterval !== undefined &&
(typeof metricsInterval !== "number" ||
metricsInterval < 5 ||
metricsInterval > 3600)
) {
return res
.status(400)
.json({ error: "metricsInterval must be between 5 and 3600 seconds" });
}
try {
const settings = createCurrentSettingsRepository();
if (statusCheckInterval !== undefined) {
await settings.set(
"global_status_check_interval",
String(statusCheckInterval),
);
}
if (metricsInterval !== undefined) {
await settings.set("global_metrics_interval", String(metricsInterval));
}
await refreshAllPolling();
res.json({
success: true,
message: "Settings updated and polling refreshed",
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.error(
"Settings table does not exist, cannot save settings",
{
operation: "global_settings_table_check",
error: error.message,
},
);
return res.status(500).json({
error:
"Database settings table is missing. Please check database initialization.",
});
}
statsLogger.error("Failed to save global settings", {
operation: "global_settings_save_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: "Failed to save global settings",
details: error instanceof Error ? error.message : String(error),
});
}
});
/**
* @openapi
* /global-settings/history:
* get:
* summary: Get metrics history retention setting
* tags:
* - Host Metrics
* responses:
* 200:
* description: Retention setting in days.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings/history", requireAdmin, async (_req, res) => {
try {
const value = await createCurrentSettingsRepository().get(
"metrics_history_retention_days",
);
const days = value ? parseInt(value, 10) : 7;
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
} catch (error) {
statsLogger.error("Failed to fetch history retention setting", {
operation: "history_retention_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch history retention setting" });
}
});
/**
* @openapi
* /global-settings/history:
* post:
* summary: Update metrics history retention setting
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* metricsHistoryRetentionDays:
* type: integer
* minimum: 1
* maximum: 90
* responses:
* 200:
* description: Setting saved.
* 400:
* description: Invalid value.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings/history", requireAdmin, async (req, res) => {
const { metricsHistoryRetentionDays } = req.body;
if (
typeof metricsHistoryRetentionDays !== "number" ||
metricsHistoryRetentionDays < 1 ||
metricsHistoryRetentionDays > 90
) {
return res.status(400).json({
error: "metricsHistoryRetentionDays must be between 1 and 90",
});
}
try {
await createCurrentSettingsRepository().set(
"metrics_history_retention_days",
String(Math.floor(metricsHistoryRetentionDays)),
);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save history retention setting", {
operation: "history_retention_save_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to save history retention setting" });
}
});
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+439
View File
@@ -0,0 +1,439 @@
export type HostStatus = "online" | "reachable" | "offline";
export class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>();
private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => {
try {
const result = await Promise.race<T>([
request(),
new Promise<never>((_, rej) =>
setTimeout(
() =>
rej(
new Error(
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
),
),
this.requestTimeout,
),
),
]);
resolve(result);
} catch (error) {
reject(error);
}
};
const queue = this.queues.get(hostId) || [];
queue.push(wrappedRequest);
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// expected
}
}
}
this.processing.delete(hostId);
const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: unknown;
timestamp: number;
hostId: number;
}
export class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): unknown | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: unknown): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
permanent: boolean;
}
export class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 5000;
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
if (record.reason === "TOTP" || record.permanent) {
return true;
}
if (record.count >= this.maxRetries) {
return true;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.reason === "HOST_KEY") {
return "SSH host key changed (verification required)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
resetHostKeyFailure(hostId: number): void {
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
export class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000;
private maxDelay = 600000;
private maxRetries = 5;
recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
const delay = Math.min(
this.baseDelay * Math.pow(2, existing.count),
this.maxDelay,
);
this.failures.set(hostId, {
count: existing.count + 1,
nextRetry: Date.now() + delay,
});
}
shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId);
if (!backoff) return false;
if (backoff.count >= this.maxRetries) {
return true;
}
return Date.now() < backoff.nextRetry;
}
getBackoffInfo(hostId: number): string | null {
const backoff = this.failures.get(hostId);
if (!backoff) return null;
if (backoff.count >= this.maxRetries) {
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
}
const remainingMs = backoff.nextRetry - Date.now();
if (remainingMs > 0) {
const remainingSec = Math.ceil(remainingMs / 1000);
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) {
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId);
}
}
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
get limit(): number {
return this.maxConcurrent;
}
/**
* Changes the ceiling at runtime.
*
* Raising it wakes the waiters the new headroom allows, so a queue that built
* up under the old limit drains at once instead of one job at a time as
* running work finishes. Lowering it never interrupts work already running;
* the new limit simply applies from the next release onward.
*/
setLimit(maxConcurrent: number): void {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
this.maxConcurrent = maxConcurrent;
this.releaseWaiters();
}
/**
* Wakes as many queued callers as there is now room for.
*
* `woken` counts callers that have been resumed but have not yet reached the
* `active += 1` on the far side of their await. Without it the occupancy
* looks lower than it really is for a microtask, and the loop would release
* past the ceiling.
*/
private releaseWaiters(): void {
while (
this.active + this.woken < this.maxConcurrent &&
this.waiters.length > 0
) {
this.woken += 1;
this.waiters.shift()!();
}
}
private woken = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
// Queue behind anything already woken, so a caller arriving mid-handoff
// cannot jump the queue and push occupancy past the ceiling.
if (this.active + this.woken >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
// Resumed by a slot handoff; that reservation is now consumed.
this.woken = Math.max(0, this.woken - 1);
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.releaseWaiters();
}
}
}
/**
* How many metrics polls may run at once for a given number of polled hosts.
*
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
* interval, so polling fell permanently behind and a host could wait over a
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
* its interval; the cap keeps file descriptors and CPU bounded.
*/
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
const MIN_METRICS_CONCURRENCY = 5;
const MAX_METRICS_CONCURRENCY = 50;
/** Aim to spend about a twentieth of the interval per sweep wave. */
const HOSTS_PER_WORKER = 20;
export function metricsConcurrencyFor(
hostCount: number,
env: NodeJS.ProcessEnv = process.env,
): number {
const override = Number(env[METRICS_CONCURRENCY_ENV]);
if (Number.isFinite(override) && override >= 1) {
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
}
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
return Math.min(
Math.max(scaled, MIN_METRICS_CONCURRENCY),
MAX_METRICS_CONCURRENCY,
);
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/**
* The first heavy SSH sample waits until core's cheap status check says the
* host answers. A host core does not check (status checks off) is tried.
*/
export function canStartInitialMetrics(
status: HostStatus | null | undefined,
hasViewers: boolean,
): boolean {
return hasViewers && status !== "offline";
}
/** Everything one activation of the plugin polls with. */
export function createMetricsState() {
return {
/** SSH metrics execs are expensive; resized with the fleet. */
metricsLimiter: new ConcurrentLimiter(5),
/** Viewer registration is bursty; admit only two first samples at a time. */
initialLimiter: new ConcurrentLimiter(2),
hostCache: new HostPollCache(30_000),
requestQueue: new RequestQueue(),
metricsCache: new MetricsCache(),
authFailures: new AuthFailureTracker(),
backoff: new PollingBackoff(),
};
}
export type MetricsState = ReturnType<typeof createMetricsState>;
+120
View File
@@ -0,0 +1,120 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
real,
refHost,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/** One card layout per user per host, as JSON. */
export const hostMetricsPreferences = adoptLegacyTable(
"host_metrics_preferences",
defineTable(
"host_metrics_preferences",
{
id: id(),
userId: refUser(),
hostId: refHost(),
layout: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_metrics_prefs_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
/** A user's health checks for a host, as a JSON array. */
export const hostHealthChecks = adoptLegacyTable(
"host_health_checks",
defineTable(
"host_health_checks",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checks: text().notNull(),
intervalSeconds: integer().notNull().default(300),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_health_checks_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
export const hostHealthHistory = adoptLegacyTable(
"host_health_history",
defineTable(
"host_health_history",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checkId: varchar().notNull(),
ts: timestamp().notNull().defaultNow(),
ok: boolean().notNull(),
latencyMs: integer(),
detail: text(),
},
{
indexes: [
{
name: "idx_host_health_history_lookup",
columns: ["userId", "hostId", "checkId", "ts"],
},
],
},
),
);
/** One row per metrics sample, pruned to the retention setting. */
export const hostMetricsHistory = adoptLegacyTable(
"host_metrics_history",
defineTable(
"host_metrics_history",
{
id: id(),
hostId: refHost(),
ts: timestamp().notNull().defaultNow(),
cpuPercent: real(),
memPercent: real(),
diskPercent: real(),
netRxBytes: integer(),
netTxBytes: integer(),
},
{
indexes: [
{
name: "idx_host_metrics_history_host_ts",
columns: ["hostId", "ts"],
},
],
},
),
);
export const tables = [
hostMetricsPreferences,
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
];
+17
View File
@@ -0,0 +1,17 @@
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
@@ -1,291 +0,0 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
type ViewerStatsConfig = {
metricsEnabled: boolean;
};
type HostMetricsViewerRoutesDeps<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
> = {
fetchHostById: (hostId: number, userId: string) => Promise<THost>;
supportsMetrics: (host: THost) => boolean;
parseStatsConfig: (statsConfig: THost["statsConfig"]) => TStatsConfig;
updateHeartbeat: (viewerSessionId: string) => boolean;
registerViewer: (
hostId: number,
viewerSessionId: string,
userId: string,
) => void;
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
/** Route path segment, e.g. "metrics" -> /metrics/heartbeat. Defaults to "metrics". */
pathPrefix?: string;
};
export function registerHostMetricsViewerRoutes<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
>(
app: Express,
{
fetchHostById,
supportsMetrics,
parseStatsConfig,
updateHeartbeat,
registerViewer,
unregisterViewer,
pathPrefix = "metrics",
}: HostMetricsViewerRoutesDeps<THost, TStatsConfig>,
): void {
/**
* @openapi
* /metrics/heartbeat:
* post:
* summary: Update viewer heartbeat
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Heartbeat updated successfully.
* 400:
* description: Invalid viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Viewer session not found.
* 500:
* description: Failed to update heartbeat.
*/
app.post(`/${pathPrefix}/heartbeat`, async (req, res) => {
const { viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
const success = updateHeartbeat(viewerSessionId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Viewer session not found" });
}
} catch (error) {
statsLogger.error("Failed to update heartbeat", {
operation: "heartbeat_error",
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to update heartbeat" });
}
});
/**
* @openapi
* /metrics/register-viewer:
* post:
* summary: Register metrics viewer
* description: Registers a new viewer session for a host to track who is viewing metrics.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Viewer registered successfully.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to register viewer.
*/
app.post(`/${pathPrefix}/register-viewer`, async (req, res) => {
const { hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
// Graceful no-op if host is inaccessible, metrics disabled, or host type
// does not support metrics. The client may call this speculatively, so
// avoid returning 5xx for expected "no metrics available" scenarios.
let host: THost | undefined;
try {
host = await fetchHostById(hostId, userId);
} catch (lookupErr) {
statsLogger.warn(
"register-viewer host lookup failed (treating as no-op)",
{
operation: "register_viewer_lookup",
hostId,
userId,
error: getErrorMessage(lookupErr, String(lookupErr)),
},
);
}
if (!host) {
return res.json({
success: true,
skipped: true,
reason: "host_not_found",
});
}
if (!supportsMetrics(host)) {
return res.json({
success: true,
skipped: true,
reason: "metrics_unsupported",
});
}
const statsConfig = parseStatsConfig(host.statsConfig);
if (!statsConfig.metricsEnabled) {
return res.json({
success: true,
skipped: true,
reason: "metrics_disabled",
});
}
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
registerViewer(hostId, viewerSessionId, userId);
} catch (regErr) {
statsLogger.warn(
"pollingManager.registerViewer threw (treating as no-op)",
{
operation: "register_viewer_internal",
hostId,
userId,
error: regErr instanceof Error ? regErr.message : String(regErr),
},
);
return res.json({
success: true,
skipped: true,
reason: "register_failed_noop",
});
}
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
userId,
error: error instanceof Error ? error.message : String(error),
});
// Even on unexpected errors we prefer a graceful client experience: the
// viewer-registration is purely an optimization and should never break
// the UI. Report success:false but HTTP 200 so the client can decide.
res.status(200).json({
success: false,
skipped: true,
reason: "internal_error",
});
}
});
/**
* @openapi
* /metrics/unregister-viewer:
* post:
* summary: Unregister metrics viewer
* description: Unregisters a viewer session when they stop viewing metrics for a host.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Viewer unregistered successfully.
* 400:
* description: Invalid hostId or viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to unregister viewer.
*/
app.post(`/${pathPrefix}/unregister-viewer`, async (req, res) => {
const { hostId, viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
unregisterViewer(hostId, viewerSessionId);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to unregister viewer", {
operation: "unregister_viewer_error",
hostId,
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to unregister viewer" });
}
});
}
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
/**
* CPU percent is derived from the delta against the previous poll's
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
@@ -1,10 +1,10 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../../../src/types/stats-widgets.js";
} from "../../shared/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
@@ -1,8 +1,6 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface GpuDevice {
index: number;
@@ -1,5 +1,5 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface LoginRecord {
user: string;
@@ -1,11 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
kibToGiB,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum, kibToGiB } from "../util.js";
import type { Client } from "ssh2";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
export interface NetworkCounters {
rx: string;
@@ -1,9 +1,9 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
PortsMetrics,
ListeningPort,
} from "../../../../../src/types/stats-widgets.js";
} from "../../shared/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
@@ -1,5 +1,5 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
@@ -1,8 +1,6 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface TemperatureSensor {
label: string;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);