mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user