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

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

No files matched your search

@@ -1,81 +0,0 @@
/**
* One-line hand-off from the metrics poller onto the ctx.events bus.
*
* The poller is already a very large module, so the hooks it calls live here
* instead. Everything is fire-and-forget: a failure in a subscriber must never
* disturb metric collection, and publishing onto the bus creates no static edge
* to the automations layer (which reads repositories the metrics module also
* pulls in).
*
* For the generic (non-metrics) internal event notifier, see
* `hosts/automation-events.ts` -- that one is shared across features.
*/
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
import type { MetricsSnapshot } from "../../../automations/src/backend/conditions.js";
export interface HostMetricsPayload {
hostId: number;
ownerUserId: string;
metrics: MetricsSnapshot;
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
online: boolean;
}
export interface HostHealthCheckPayload {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export function notifyAutomationMetrics(
hostId: number,
ownerUserId: string,
metrics: MetricsSnapshot,
): void {
if (!ownerUserId) return;
pluginEvents.emit(TOPICS.hostMetrics, {
hostId,
ownerUserId,
metrics,
} satisfies HostMetricsPayload);
}
export function notifyAutomationStatus(
hostId: number,
ownerUserId: string,
online: boolean,
): void {
if (!ownerUserId) return;
pluginEvents.emit(TOPICS.hostStatus, {
hostId,
ownerUserId,
online,
} satisfies HostStatusPayload);
}
export function notifyAutomationHealthCheck(
hostId: number,
userId: string,
checkId: string,
ok: boolean,
detail?: string,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.hostHealthCheck, {
hostId,
userId,
checkId,
ok,
detail,
} satisfies HostHealthCheckPayload);
}
+196
View File
@@ -0,0 +1,196 @@
import type { Client } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import { detectHostPlatform } from "@termix/plugin-sdk/host-commands";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { collectDiskMetrics } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import type { FirewallMetrics, PortsMetrics } from "../shared/stats-widgets.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
import type { MetricsState } from "./state.js";
import type { CollectorRegistry } from "./collectors.js";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("Host denied (verification failed)") ||
error.message.includes("Host key changed"))
);
}
export type CollectedMetrics = {
cpu: Awaited<ReturnType<typeof collectCpuMetrics>>;
memory: Awaited<ReturnType<typeof collectMemoryMetrics>>;
disk: Awaited<ReturnType<typeof collectDiskMetrics>>;
network: Awaited<ReturnType<typeof collectNetworkMetrics>>;
uptime: Awaited<ReturnType<typeof collectUptimeMetrics>>;
processes: Awaited<ReturnType<typeof collectProcessesMetrics>>;
system: Awaited<ReturnType<typeof collectSystemMetrics>>;
login_stats: Awaited<ReturnType<typeof collectLoginStats>>;
ports: PortsMetrics;
firewall: FirewallMetrics;
temperature: Awaited<ReturnType<typeof collectTemperatureMetrics>>;
gpu: GpuMetrics;
/** Results from other plugins' collectors, by collector id. */
extra?: Record<string, unknown>;
};
export interface CollectDeps {
ssh: PluginSsh;
state: MetricsState;
sessions: MetricsSessions;
collectors: CollectorRegistry;
}
async function optional<T>(collect: () => Promise<T>, fallback: T) {
try {
return await collect();
} catch {
return fallback;
}
}
/**
* One sample. Reuses a connection the person opened from the tab when there
* is one, otherwise borrows the "stats" pool, which answers password prompts
* only and fails fast on anything that needs a person.
*/
export async function collectMetrics(
deps: CollectDeps,
host: MetricsHost,
mounts: {
excludedMounts: string[];
monitoredMounts: Array<{ path: string; label?: string }>;
},
onAuthenticated?: () => void,
): Promise<CollectedMetrics> {
const { ssh, state, sessions, collectors } = deps;
if (!supportsMetrics(host, ssh)) {
throw new Error("Metrics collection only supported for SSH hosts");
}
if (state.authFailures.shouldSkip(host.id)) {
const reason = state.authFailures.getSkipReason(host.id);
throw new Error(reason || "Authentication failed");
}
const cached = state.metricsCache.get(host.id) as CollectedMetrics | null;
if (cached) {
onAuthenticated?.();
return cached;
}
return state.requestQueue.queueRequest(host.id, async () => {
const key = sessionKey(host.id, host.userId);
const existing = sessions.get(key);
try {
const collect = async (client: Client): Promise<CollectedMetrics> => {
onAuthenticated?.();
const platform = await detectHostPlatform(client);
const result: CollectedMetrics = {
cpu: await collectCpuMetrics(client, platform, host.id),
memory: await collectMemoryMetrics(client, platform),
disk: await collectDiskMetrics(
client,
mounts.excludedMounts,
mounts.monitoredMounts,
platform,
),
network: await collectNetworkMetrics(client, platform, host.id),
uptime: await collectUptimeMetrics(client, platform),
processes: await collectProcessesMetrics(client),
system: await collectSystemMetrics(client, platform),
login_stats: await optional(() => collectLoginStats(client), {
recentLogins: [],
failedLogins: [],
totalLogins: 0,
uniqueIPs: 0,
}),
ports: await optional(() => collectPortsMetrics(client), {
source: "none",
ports: [],
}),
firewall: await optional(() => collectFirewallMetrics(client), {
type: "none",
status: "unknown",
chains: [],
}),
temperature: await optional(() => collectTemperatureMetrics(client), {
source: "none",
highestCelsius: null,
sensors: [],
}),
gpu: await optional(() => collectGpuMetrics(client), {
source: "none",
gpus: [],
processes: [],
}),
};
const extra = await collectors.run(client, host.id, host.userId);
if (Object.keys(extra).length > 0) result.extra = extra;
state.metricsCache.set(host.id, result);
return result;
};
if (existing?.isConnected) {
existing.activeOperations++;
try {
const result = await collect(existing.client);
sessions.touch(key);
return result;
} finally {
existing.activeOperations--;
}
}
return await ssh.withConnection<CollectedMetrics, Client>(
host,
{
pool: "stats",
purpose: "metrics",
overrides: { readyTimeout: 60000 },
},
collect,
);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("TOTP authentication required")) {
throw error;
} else if (
error.message.includes("No password available") ||
error.message.includes("Unsupported authentication type") ||
error.message.includes("No SSH key available") ||
error.message.includes("Invalid SSH key format")
) {
state.authFailures.recordFailure(host.id, "AUTH", true);
} else if (isHostKeyVerificationError(error)) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
} else if (
error.message.includes("authentication") ||
error.message.includes("Permission denied") ||
error.message.includes("All configured authentication methods failed")
) {
state.authFailures.recordFailure(host.id, "AUTH");
} else if (
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT")
) {
state.authFailures.recordFailure(host.id, "TIMEOUT");
}
}
throw error;
}
});
}
@@ -0,0 +1,48 @@
import type { Client } from "ssh2";
import type { PluginServices } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
export const COLLECTORS_SERVICE = "host-metrics.collectors";
/**
* The "host-metrics.collectors" service, version 1. Another plugin provides
* it under its own provider name (manifest `provides[].names`) to add data
* to every sample. It runs on the sample's SSH connection, as the host's
* owner, and its result lands in the snapshot under `extra[<name>]`. A
* collector that throws is skipped for that sample.
*/
export interface MetricsCollectorV1 {
collect: (client: Client, hostId: number) => Promise<unknown>;
}
export class CollectorRegistry {
constructor(
private readonly services: PluginServices,
private readonly log: MetricsLogger,
) {}
async run(
client: Client,
hostId: number,
ownerUserId: string,
): Promise<Record<string, unknown>> {
const results: Record<string, unknown> = {};
for (const provider of this.services.providers(COLLECTORS_SERVICE)) {
if (!provider) continue;
try {
const collector = this.services.get<Partial<MetricsCollectorV1>>(
COLLECTORS_SERVICE,
{ provider, userId: ownerUserId },
);
if (typeof collector.collect !== "function") continue;
results[provider] = await collector.collect(client, hostId);
} catch (error) {
this.log.debug(`Metrics collector ${provider} failed`, {
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
}
+38 -47
View File
@@ -1,60 +1,51 @@
import type { Client } from "ssh2";
import { pluginSsh } from "./ssh.js";
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
export type StatsCapableHost = {
connectionType?: string;
authType?: string;
/** A host as ctx.ssh.resolveHost returns it, with the fields polling reads. */
export type MetricsHost = PluginSshHost & {
userId: string;
name?: string | null;
connectionType?: string | null;
enableSsh?: boolean | null;
password?: string | null;
sudoPassword?: string | null;
terminalConfig?: { sudoPassword?: string | null } | null;
enableDocker?: boolean | null;
jumpHosts?: Array<{ hostId: number }> | null;
};
export type TcpPingStatsConfig = {
statusCheckEnabled: boolean;
disableTcpPing?: boolean;
};
export function supportsMetrics(host: StatsCapableHost): boolean {
/** Metrics run over SSH, unattended, so the auth type has to allow that. */
export function supportsMetrics(
host: Pick<MetricsHost, "connectionType" | "enableSsh" | "authType">,
ssh: Pick<PluginSsh, "supportsBackground">,
): boolean {
const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh") return false;
return pluginSsh().supportsBackground(host.authType || "none");
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
return ssh.supportsBackground(host.authType || "none");
}
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
export function sudoPasswordOf(host: MetricsHost): string | undefined {
return host.sudoPassword || host.terminalConfig?.sudoPassword || undefined;
}
export function parseStatusHostIds(value: unknown): Set<number> | null {
if (value === undefined) return null;
if (typeof value !== "string") return new Set();
export type ConnectionLogType = "info" | "success" | "warning" | "error";
return new Set(
value
.split(",")
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0),
);
export interface ConnectionLog {
type: ConnectionLogType;
stage: string;
message: string;
details?: Record<string, unknown>;
}
export function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
/** The entry shape the connection log panel reads. */
export function connectionLog(
type: ConnectionLogType,
stage: string,
message: string,
details?: Record<string, unknown>,
): ConnectionLog {
return { type, stage, message, ...(details ? { details } : {}) };
}
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
});
});
export function newSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
@@ -1,131 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
export function registerHostMetricsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
): void {
/**
* @openapi
* /metrics/history/{id}:
* get:
* summary: Get historical metrics for a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of metric history rows.
* 403:
* description: Access denied.
* 404:
* description: Host not found or no access.
*/
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
// Normalize both sides to that format for reliable string comparison.
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await createCurrentHostMetricsHistoryRepository().listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({ rows, fromTs, toTs });
} catch (error) {
statsLogger.error("Failed to fetch metrics history", {
operation: "metrics_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch metrics history" });
}
});
}
@@ -0,0 +1,32 @@
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
/**
* Registered as ctx.registry.provide("host-metrics.hostImportNormalizer", ...)
* for core's bulk host import. A Termix export carries these settings under
* pluginSettings["host-metrics"]; exports from before 2.9.0 carried them in
* statsConfig, where the host's own interval only counted when
* useGlobalMetricsInterval was false.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const bag = raw.pluginSettings as
Record<string, Record<string, unknown>> | undefined;
const own = bag?.["host-metrics"];
if (own && typeof own === "object") {
const settings = readHostMetricsSettings(own);
return { ...settings };
}
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: null;
if (!legacy) return null;
const settings = readHostMetricsSettings({
...legacy,
metricsInterval:
legacy.useGlobalMetricsInterval === false ? legacy.metricsInterval : null,
});
return { ...settings };
}
+95 -21
View File
@@ -1,34 +1,108 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import {
onHostDeleted,
onHostLogin,
onHostUpdated,
shutdown,
startHostMetricsService,
} from "./routes.js";
import { createLogger } from "./log.js";
import { createHostMetricsRepository } from "./repository.js";
import { createMetricsState } from "./state.js";
import { MetricsSessions } from "./sessions.js";
import { CollectorRegistry } from "./collectors.js";
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
import { registerRoutes } from "./routes.js";
import { hostImportNormalizer } from "./host-import.js";
import { newSessionId, supportsMetrics } from "./helpers.js";
export type { MetricsCollectorV1 } from "./collectors.js";
export type { CollectionStatusPayload, SnapshotPayload } from "./poller.js";
/** The "host-metrics.viewers" service, version 1. Runs as the caller's user. */
export interface MetricsViewersV1 {
register: (
hostId: number,
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
heartbeat: (viewerSessionId: string) => boolean;
unregister: (hostId: number, viewerSessionId: string) => void;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
await startHostMetricsService(ctx.http.router());
const log = createLogger(ctx.log);
const repository = await createHostMetricsRepository(ctx.db);
const state = createMetricsState();
const sessions = new MetricsSessions(ctx.schedule, log);
const collectors = new CollectorRegistry(ctx.services, log);
const poller = new MetricsPoller({
ctx,
log,
repository,
ssh: ctx.ssh,
state,
sessions,
collectors,
});
ctx.disposables.add(() => {
poller.dispose();
sessions.dispose();
});
registerRoutes(ctx.http.router<Router>(), {
ctx,
log,
poller,
sessions,
repository,
onHealthCheck: (event) => ctx.events.emit(TOPIC_HEALTH_CHECK, event),
});
ctx.schedule.every(60_000, () => poller.cleanupInactiveViewers());
ctx.schedule.every(10 * 60_000, () => {
state.authFailures.cleanup();
state.backoff.cleanup();
});
// Core used to POST these to this plugin's own port with an internal auth
// token. They are events now: no port, no shared secret, and the
// subscriptions are torn down with the plugin.
ctx.events.on("host.updated", (payload) => {
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
const { hostId } = payload as { hostId?: number };
if (hostId) void poller.hostUpdated(hostId);
});
ctx.events.on("host.deleted", (payload) => {
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostDeleted(hostId);
});
ctx.events.on("host.login", (payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
ctx.events.on("host.status", (payload) => {
const { hostId, status } = payload as { hostId?: number; status?: string };
if (hostId && status) poller.hostStatusChanged(hostId, status);
});
ctx.events.on("host.key.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostKeyAccepted(hostId);
});
ctx.settings.onChange("metricsInterval", () => void poller.retimeAll());
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
register: async (hostId) => {
const userId = ctx.currentActor();
if (!userId) return { skipped: true, reason: "no_user" };
const host = await poller.resolve(hostId, userId);
if (!host) return { skipped: true, reason: "host_not_found" };
if (!supportsMetrics(host, ctx.ssh)) {
return { skipped: true, reason: "metrics_unsupported" };
}
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
return { skipped: true, reason: "metrics_disabled" };
}
const viewerSessionId = newSessionId("service");
poller.registerViewer(hostId, viewerSessionId, userId);
return { viewerSessionId };
},
heartbeat: (viewerSessionId) =>
poller.updateHeartbeat(viewerSessionId, ctx.currentActor()),
unregister: (hostId, viewerSessionId) =>
poller.unregisterViewer(hostId, viewerSessionId, ctx.currentActor()),
});
ctx.registry.provide(
"host-metrics.hostImportNormalizer",
hostImportNormalizer,
);
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
export async function deactivate() {
shutdown();
}
export async function deactivate() {}
@@ -0,0 +1,367 @@
import { Client, type ConnectConfig } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import {
connectionLog,
newSessionId,
type ConnectionLog,
type MetricsHost,
} from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
const CONNECT_TIMEOUT_MS = 60_000;
const TOTP_TTL_MS = 180_000;
const MAX_TOTP_ATTEMPTS = 3;
export type StartResult =
| { status: 200; body: Record<string, unknown> }
| { status: 400 | 401 | 500; body: Record<string, unknown> };
export interface InteractiveDeps {
ssh: PluginSsh;
sessions: MetricsSessions;
log: MetricsLogger;
registerViewer: (hostId: number, sessionId: string, userId: string) => void;
}
function stageForError(message: string): [string, string] {
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
return ["dns", `DNS resolution failed: ${message}`];
}
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
return ["tcp", `TCP connection failed: ${message}`];
}
if (message.includes("handshake") || message.includes("key exchange")) {
return ["handshake", `SSH handshake failed: ${message}`];
}
if (/authentication/i.test(message)) {
return ["auth", `Authentication failed: ${message}`];
}
if (message.includes("verification failed")) {
return [
"handshake",
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
];
}
return ["error", `SSH connection failed: ${message}`];
}
/**
* Opens a connection from the tab, where a person can answer a TOTP prompt,
* and keeps it for polling. Resolves with the HTTP answer.
*/
export async function startInteractive(
deps: InteractiveDeps,
host: MetricsHost,
userId: string,
): Promise<StartResult> {
const { ssh, sessions, log } = deps;
const logs: ConnectionLog[] = [
connectionLog("info", "stats_connecting", "Starting metrics collection"),
connectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
connectionLog("info", "handshake", "Initiating SSH handshake"),
];
if (host.authType === "password") {
logs.push(connectionLog("info", "auth", "Authenticating with password"));
} else if (host.authType === "key") {
logs.push(connectionLog("info", "auth", "Authenticating with SSH key"));
}
const key = sessionKey(host.id, userId);
if (sessions.get(key)?.isConnected) {
logs.push(
connectionLog(
"success",
"stats_polling",
"Using existing metrics session",
),
);
return { status: 200, body: { success: true, connectionLogs: logs } };
}
const client = new Client();
const prepared = await ssh.prepare(host, {
purpose: "metrics",
client,
log: (level, message) => logs.push(connectionLog(level, "auth", message)),
});
const config = prepared.config as ConnectConfig;
config.readyTimeout = CONNECT_TIMEOUT_MS;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
logs.push(connectionLog("error", "auth", outcome.message));
if (outcome.status === "interaction-required") {
return {
status: 401,
body: {
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs: logs,
},
};
}
return {
status: 400,
body: { error: outcome.message, connectionLogs: logs },
};
}
try {
const result = await new Promise<Record<string, unknown>>(
(resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(
() =>
settle(() => {
client.end();
reject(new Error("Connection timeout"));
}),
CONNECT_TIMEOUT_MS,
);
client.on(
"keyboard-interactive",
(name, instructions, _lang, prompts, finish) => {
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
if (decision.kind !== "totp") {
finish(ssh.autoResponses(prompts, host.password));
return;
}
const sessionId = newSessionId("totp");
sessions.addPending({
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId,
prompts: prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo ?? false,
})),
totpPromptIndex: decision.promptIndex,
resolvedPassword: host.password ?? undefined,
totpAttempts: 0,
});
logs.push(
connectionLog("info", "stats_totp", "TOTP verification required"),
);
settle(() =>
resolve({
success: false,
requires_totp: true,
sessionId,
prompt: prompts[decision.promptIndex].prompt,
}),
);
},
);
client.on("ready", () =>
settle(() => {
logs.push(
connectionLog(
"success",
"connected",
"SSH connection established successfully",
),
connectionLog(
"success",
"stats_polling",
"Metrics session established",
),
);
sessions.open(key, {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: host.id,
userId,
});
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(host.id, viewerSessionId, userId);
resolve({ success: true, viewerSessionId });
}),
);
client.on("error", (error) =>
settle(() => {
const message =
error instanceof Error ? error.message : String(error);
const [stage, text] = stageForError(message);
logs.push(connectionLog("error", stage, text));
log.error("SSH connection error in metrics/start", {
operation: "metrics_start_ssh_error",
hostId: host.id,
error: message,
});
reject(error);
}),
);
if (Array.isArray(host.jumpHosts) && host.jumpHosts.length > 0) {
logs.push(
connectionLog("info", "proxy", "Connecting via jump host chain"),
);
}
ssh
.openTransport(host, config as Record<string, unknown>)
.then((transport) => {
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as Client;
client.on("close", () => jumpClient.end());
}
client.connect(config);
})
.catch((error) =>
settle(() => {
logs.push(
connectionLog(
"error",
"proxy",
`Connection setup failed: ${error instanceof Error ? error.message : String(error)}`,
),
);
reject(error);
}),
);
},
);
return { status: 200, body: { ...result, connectionLogs: logs } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logs.push(
connectionLog(
"error",
"stats_connecting",
`Failed to start metrics: ${message}`,
),
);
return {
status: 500,
body: {
error: message || "Failed to start metrics collection",
connectionLogs: logs,
},
};
}
}
export type TotpResult = { status: number; body: Record<string, unknown> };
/** Sends the TOTP code a person typed and keeps the connection on success. */
export async function submitTotp(
deps: InteractiveDeps,
userId: string,
sessionId: string,
totpCode: string,
): Promise<TotpResult> {
const { sessions, log } = deps;
const session = sessions.getPending(sessionId);
if (!session) {
return {
status: 404,
body: { error: "TOTP session not found or expired" },
};
}
if (Date.now() - session.createdAt > TOTP_TTL_MS) {
sessions.dropPending(sessionId, true);
return { status: 408, body: { error: "TOTP session timeout" } };
}
if (session.userId !== userId) {
return { status: 403, body: { error: "Unauthorized" } };
}
session.totpAttempts++;
if (session.totpAttempts > MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
return { status: 429, body: { error: "Too many TOTP attempts" } };
}
const passwordFor = (prompt: string) =>
/password/i.test(prompt) && session.resolvedPassword
? session.resolvedPassword
: "";
try {
const responses = (session.prompts || []).map((p, index) =>
index === session.totpPromptIndex
? totpCode.trim()
: passwordFor(p.prompt),
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("TOTP verification timeout")),
30_000,
);
session.client.once(
"keyboard-interactive",
(_name, _instructions, _lang, prompts, finish) => {
log.warn("Second keyboard-interactive received after TOTP", {
operation: "totp_second_keyboard_interactive",
hostId: session.hostId,
});
finish(prompts.map((p) => passwordFor(p.prompt)));
},
);
session.client.once("ready", () => {
clearTimeout(timeout);
resolve();
});
session.client.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
session.finish(responses);
await ready;
sessions.open(sessionKey(session.hostId, userId), {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: session.hostId,
userId,
});
sessions.dropPending(sessionId);
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(session.hostId, viewerSessionId, userId);
return { status: 200, body: { success: true, viewerSessionId } };
} catch (error) {
log.error("TOTP verification failed", {
operation: "totp_verification_failed",
hostId: session.hostId,
error: error instanceof Error ? error.message : String(error),
});
if (session.totpAttempts >= MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
}
return {
status: 401,
body: {
error: "TOTP verification failed",
attemptsRemaining: Math.max(
0,
MAX_TOTP_ATTEMPTS - session.totpAttempts,
),
},
};
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type Meta = Record<string, unknown>;
export interface MetricsLogger {
debug: (message: string, meta?: Meta) => void;
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
/** An error, or structured context, or both. */
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
return meta && Object.keys(meta).length > 0
? `${message} ${JSON.stringify(meta)}`
: message;
}
/** ctx.log with the structured context the old stats logger carried. */
export function createLogger(log: PluginLogger): MetricsLogger {
return {
debug: (message, meta) => log.debug(withMeta(message, meta)),
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, errorOrMeta, meta) => {
if (errorOrMeta instanceof Error) {
log.error(withMeta(message, meta), errorOrMeta);
return;
}
log.error(
withMeta(message, {
...((errorOrMeta as Meta | undefined) ?? {}),
...meta,
}),
);
},
};
}
@@ -1,7 +1,9 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
@@ -78,14 +80,12 @@ export function buildApplyCrontabCommand(body: string): string {
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
managerHandler(deps, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
@@ -95,7 +95,7 @@ export function registerCronRoutes(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"cron_replace",
async (client, _host, req) => {
@@ -1,15 +1,14 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
@@ -45,13 +44,14 @@ export function buildNftRuleCommand(
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
managerHandler(deps, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
@@ -60,7 +60,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_rule",
async (client, host, req) => {
@@ -105,7 +105,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_persist",
async (client, host) => {
@@ -1,13 +1,13 @@
import type { Express } from "express";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { isValidPort } from "./validation.js";
import type { Router } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
import type { HostMetricsRepository } from "../repository.js";
export interface HealthCheck {
id: string;
@@ -111,24 +111,12 @@ async function runChecks(
const HISTORY_KEEP = 500;
function recordHistory(
userId: string,
hostId: number,
results: HealthResult[],
): Promise<void> {
return createCurrentHostHealthRepository()
.recordHistory(userId, hostId, results, HISTORY_KEEP)
.then(() => undefined);
}
async function loadChecks(
repository: HostMetricsRepository,
userId: string,
hostId: number,
): Promise<HealthCheck[]> {
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
userId,
hostId,
);
const row = await repository.findChecks(userId, hostId);
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
@@ -139,60 +127,50 @@ async function loadChecks(
}
export function registerHealthRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, repository } = deps;
const record = async (
userId: string,
hostId: number,
results: HealthResult[],
) => {
if (!results.length) return;
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
for (const result of results) {
deps.onHealthCheck({
hostId,
userId,
checkId: result.checkId,
ok: result.ok,
detail: result.detail ?? undefined,
});
}
};
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
const history = (
await createCurrentHostHealthRepository().listHistory(
userId,
host.id,
200,
)
).map((row) => ({
checkId: row.checkId,
ts: row.ts,
ok: row.ok,
latencyMs: row.latencyMs,
detail: row.detail,
}));
return { checks, results, history };
},
),
managerHandler(deps, "connect", "health_get", async (client, host) => {
const userId = host.actorId;
const checks = await loadChecks(repository, userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
await record(userId, host.id, results);
const history = await repository.listHealth(userId, host.id, 200);
return { checks, results, history };
}),
);
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const userId = host.actorId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
@@ -206,13 +184,11 @@ export function registerHealthRoutes(
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
const now = new Date().toISOString();
await createCurrentHostHealthRepository().upsertChecks(
await repository.saveChecks(
userId,
host.id,
JSON.stringify(checks),
interval,
now,
);
return { success: true };
},
@@ -222,28 +198,11 @@ export function registerHealthRoutes(
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
return { results };
},
),
managerHandler(deps, "connect", "health_run", async (client, host) => {
const checks = await loadChecks(repository, host.actorId, host.id);
const results = await runChecks(client, checks);
await record(host.actorId, host.id, results);
return { results };
}),
);
}
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
@@ -19,10 +19,10 @@ import { registerWireGuardRoutes } from "./wireguard.js";
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Express,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, runOnHost } = deps;
const { validateHostId } = deps;
/**
* @openapi
@@ -44,7 +44,7 @@ export function registerManagerRoutes(
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
managerHandler(deps, "connect", "platform_detect", (client) =>
detectPlatform(client),
),
);
@@ -1,14 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "@termix/plugin-sdk/host-commands";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isAllowedPath,
isValidSystemdUnit,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
@@ -44,14 +41,12 @@ export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
managerHandler(deps, "connect", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
@@ -65,33 +60,28 @@ export function registerLogRoutes(
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
},
),
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
}),
);
}
@@ -1,25 +1,26 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import {
execCommand,
execElevated,
detectPlatform,
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
} from "@termix/plugin-sdk/host-commands";
import { isValidPackageName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
managerHandler(deps, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
@@ -35,7 +36,7 @@ export function registerPackageRoutes(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"packages_action",
async (client, host, req) => {
@@ -1,12 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPid,
isValidSignal,
type Signal,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
@@ -54,9 +49,10 @@ export function buildKillCommand(pid: number, signal: Signal): string {
}
export function registerProcessRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/processes/{id}:
@@ -74,7 +70,7 @@ export function registerProcessRoutes(
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
managerHandler(deps, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
@@ -109,7 +105,7 @@ export function registerProcessRoutes(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"processes_signal",
async (client, host, req) => {
@@ -1,11 +1,10 @@
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
import { ElevationError } from "@termix/plugin-sdk/host-commands";
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { statsLogger } from "../../../../../src/backend/utils/logger.js";
import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import { errorMessage } from "../util.js";
import type { MetricsLogger } from "../log.js";
import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -26,16 +25,15 @@ export class ManagerInputError extends Error {
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
runOnHost: RunOnHost,
level: HostAction,
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
level: PluginHostShareLevel,
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, userId, level, (client, host) =>
const result = await runOnHost(hostId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
@@ -49,13 +47,13 @@ export function managerHandler(
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
statsLogger.error(`Manager operation failed: ${operation}`, {
log.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: getErrorMessage(error, "Operation failed"),
error: errorMessage(error, "Operation failed"),
});
}
};
@@ -1,12 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
@@ -50,9 +49,10 @@ export function buildServiceActionCommand(
}
export function registerServiceRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/services/{id}:
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "connect", "services_list", async (client) => {
managerHandler(deps, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
@@ -105,7 +105,7 @@ export function registerServiceRoutes(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"services_action",
async (client, host, req) => {
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
@@ -106,13 +106,14 @@ export function parseDfMounts(output: string): MountUsage[] {
}
export function registerSimpleReadRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
managerHandler(deps, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
@@ -121,7 +122,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
managerHandler(deps, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
@@ -130,7 +131,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
+87 -104
View File
@@ -1,16 +1,16 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
detectPlatform,
} from "@termix/plugin-sdk/host-commands";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
@@ -149,14 +149,12 @@ export function buildRevokeCommand(client: AcmeClient, name: string): string {
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
@@ -188,78 +186,68 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
/**
@@ -290,31 +278,26 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
}
@@ -1,28 +1,43 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import type { HostMetricsRepository } from "../repository.js";
import type { MetricsLogger } from "../log.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
/** The user making the request, who may not own the host. */
actorId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* user has at least `level` access. Resolves the host (with sudoPassword) so
* managers can elevate. Rejects with an access error if not permitted.
* acting user has at least `level` access. Resolves the host (with
* sudoPassword) so managers can elevate. Rejects with an access error if not
* permitted.
*/
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: HostAction,
level: PluginHostShareLevel,
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface HealthCheckEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
log: MetricsLogger;
repository: HostMetricsRepository;
onHealthCheck: (event: HealthCheckEvent) => void;
}
@@ -1,11 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidUsername,
isValidGroupName,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
@@ -75,14 +71,12 @@ export function parseSudoers(output: string): string[] {
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "connect", "users_list", async (client) => {
managerHandler(deps, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
@@ -100,7 +94,7 @@ export function registerUserRoutes(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"users_action",
async (client, host, req) => {
@@ -0,0 +1,127 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
@@ -1,11 +1,11 @@
import type { Express } from "express";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface WireGuardPeer {
publicKey: string;
@@ -117,9 +117,10 @@ export function parseWireGuardData(output: string): WireGuardData {
}
export function registerWireGuardRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
@@ -140,23 +141,13 @@ export function registerWireGuardRoutes(
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
client,
PROBE_CMD,
host.sudoPassword,
{
forceSudo: false,
timeoutMs: 15000,
},
);
return parseWireGuardData(result.stdout);
},
),
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
forceSudo: false,
timeoutMs: 15000,
});
return parseWireGuardData(result.stdout);
}),
);
/**
@@ -192,7 +183,7 @@ export function registerWireGuardRoutes(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"wireguard_action",
async (client, host, req) => {
+469
View File
@@ -0,0 +1,469 @@
import type {
PluginContext,
PluginHostStatusEntry,
} from "@termix/plugin-sdk/backend";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
collectMetrics,
isHostKeyVerificationError,
type CollectDeps,
type CollectedMetrics,
} from "./collect.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import type { HostMetricsRepository } from "./repository.js";
import type { MetricsViewer } from "./sessions.js";
import {
canStartInitialMetrics,
metricsConcurrencyFor,
type MetricsState,
} from "./state.js";
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
import { clearNetworkSampleCache } from "./widgets/network-collector.js";
export const DEFAULT_METRICS_INTERVAL = 30;
export const DEFAULT_RETENTION_DAYS = 7;
/** Viewers that stop sending heartbeats are dropped after this long. */
const VIEWER_TIMEOUT_MS = 120_000;
export const TOPIC_SNAPSHOT = "plugin.host-metrics.snapshot";
export const TOPIC_STATUS = "plugin.host-metrics.status";
export const TOPIC_HEALTH_CHECK = "plugin.host-metrics.health-check";
/** Where collection stands for a host, sent when it changes. */
export type CollectionState =
"collecting" | "auth_failed" | "host_key_changed" | "unreachable";
export interface SnapshotPayload {
hostId: number;
ownerUserId: string;
metrics: CollectedMetrics;
}
export interface CollectionStatusPayload {
hostId: number;
ownerUserId: string;
state: CollectionState;
previous: CollectionState | null;
}
interface Polled {
host: MetricsHost;
viewerUserId: string;
stop: () => void;
}
export interface PollerDeps extends CollectDeps {
ctx: PluginContext;
log: MetricsLogger;
repository: HostMetricsRepository;
}
/**
* Heavy SSH metrics for hosts somebody is looking at. Polling starts with the
* first viewer and stops with the last one; a viewer is a tab, a dashboard
* card, or another plugin through the "host-metrics.viewers" service.
*/
export class MetricsPoller {
private readonly polled = new Map<number, Polled>();
private readonly metrics = new Map<
number,
{ data: CollectedMetrics; timestamp: number }
>();
private readonly viewers = new Map<number, Set<string>>();
private readonly viewerDetails = new Map<string, MetricsViewer>();
private readonly inFlight = new Set<number>();
private readonly initialRequested = new Set<number>();
private readonly collectionState = new Map<number, CollectionState>();
constructor(private readonly deps: PollerDeps) {}
private get ctx() {
return this.deps.ctx;
}
private get state(): MetricsState {
return this.deps.state;
}
getMetrics(hostId: number) {
return this.metrics.get(hostId);
}
isPolling(hostId: number): boolean {
return this.polled.has(hostId);
}
hasViewers(hostId: number): boolean {
return (this.viewers.get(hostId)?.size ?? 0) > 0;
}
registerViewer(hostId: number, sessionId: string, userId: string): void {
let set = this.viewers.get(hostId);
if (!set) {
set = new Set();
this.viewers.set(hostId, set);
}
set.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (set.size === 1) {
// Never let a failed start reach the request that registered it.
void this.start(hostId, userId).catch((error) => {
this.deps.log.warn("Could not start metrics polling", {
operation: "start_metrics_unhandled",
hostId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
/** False when the viewer is gone, so the caller registers again. */
updateHeartbeat(sessionId: string, userId?: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (!viewer || (userId && viewer.userId !== userId)) return false;
viewer.lastHeartbeat = Date.now();
return true;
}
unregisterViewer(hostId: number, sessionId: string, userId?: string): void {
const viewer = this.viewerDetails.get(sessionId);
if (viewer && userId && viewer.userId !== userId) return;
const set = this.viewers.get(hostId);
if (set) {
set.delete(sessionId);
if (set.size === 0) {
this.viewers.delete(hostId);
this.stop(hostId, false);
}
}
this.viewerDetails.delete(sessionId);
}
/** Stops a host's polling without dropping its viewers. */
pause(hostId: number): void {
this.stop(hostId, false);
}
cleanupInactiveViewers(now = Date.now()): void {
for (const [sessionId, viewer] of [...this.viewerDetails.entries()]) {
if (now - viewer.lastHeartbeat > VIEWER_TIMEOUT_MS) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
/** A host's details changed: drop what was cached and start over. */
async hostUpdated(hostId: number): Promise<void> {
this.state.hostCache.invalidate(hostId);
this.state.metricsCache.clear(hostId);
this.ctx.ssh.dropPooled("stats", hostId);
const polled = this.polled.get(hostId);
if (polled) await this.start(hostId, polled.viewerUserId);
}
hostDeleted(hostId: number): void {
this.stop(hostId, true);
for (const sessionId of this.viewers.get(hostId) ?? []) {
this.viewerDetails.delete(sessionId);
}
this.viewers.delete(hostId);
this.collectionState.delete(hostId);
this.state.authFailures.reset(hostId);
this.state.backoff.reset(hostId);
}
/** Core's status check says the host answers again. */
hostStatusChanged(hostId: number, status: string): void {
if (status === "offline") return;
const polled = this.polled.get(hostId);
if (polled) this.scheduleInitialPoll(polled.host);
}
hostKeyAccepted(hostId: number): void {
this.state.authFailures.resetHostKeyFailure(hostId);
}
/** Re-times every running host, after the admin interval changed. */
async retimeAll(): Promise<void> {
for (const [hostId, polled] of [...this.polled.entries()]) {
await this.start(hostId, polled.viewerUserId);
}
}
dispose(): void {
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
this.viewers.clear();
this.viewerDetails.clear();
clearCpuSampleCache();
clearNetworkSampleCache();
}
/** The host as `userId` may reach it, cached for a few minutes. */
async resolve(hostId: number, userId: string): Promise<MetricsHost | null> {
const cached = this.state.hostCache.get(
hostId,
userId,
) as MetricsHost | null;
if (cached) return cached;
const host = (await this.ctx.asUser(userId, () =>
this.ctx.ssh.resolveHost(hostId),
)) as MetricsHost | null;
if (!host) return null;
this.state.hostCache.set(hostId, userId, host);
return host;
}
async settingsFor(hostId: number) {
const settings = readHostMetricsSettings(
await this.ctx.settings.getAll("host", hostId),
);
const adminInterval = Number(
await this.ctx.settings.get<number>("metricsInterval"),
);
return {
...settings,
intervalSeconds:
settings.metricsInterval ??
(Number.isInteger(adminInterval) && adminInterval >= 5
? adminInterval
: DEFAULT_METRICS_INTERVAL),
};
}
private async start(hostId: number, userId: string): Promise<void> {
const host = await this.resolve(hostId, userId);
this.stop(hostId, false);
if (!host || !supportsMetrics(host, this.ctx.ssh)) return;
const settings = await this.settingsFor(hostId);
if (!settings.metricsEnabled) {
this.metrics.delete(hostId);
return;
}
const intervalMs = settings.intervalSeconds * 1000;
const stop = this.ctx.schedule.every(
intervalMs,
() => {
const latest = this.polled.get(hostId);
if (latest) this.schedulePoll(latest.host);
},
// Spread timers so a fleet does not fire on the same second.
{ jitterMs: Math.min(intervalMs * 0.2, 15_000) },
);
this.polled.set(hostId, { host, viewerUserId: userId, stop });
this.syncConcurrency();
this.scheduleInitialPoll(host);
}
private stop(hostId: number, clearData: boolean): void {
const polled = this.polled.get(hostId);
if (polled) {
polled.stop();
this.polled.delete(hostId);
this.syncConcurrency();
}
this.inFlight.delete(hostId);
if (clearData) {
this.metrics.delete(hostId);
this.state.hostCache.invalidate(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
}
}
/** Keeps poll concurrency matched to how many hosts are being polled. */
private syncConcurrency(): void {
const target = metricsConcurrencyFor(this.polled.size);
const limiter = this.state.metricsLimiter;
if (target === limiter.limit) return;
const previous = limiter.limit;
limiter.setLimit(target);
this.deps.log.info(
`Metrics poll concurrency ${previous} -> ${target} for ${this.polled.size} host(s)`,
{ operation: "metrics_concurrency_resize" },
);
}
/**
* The first heavy sample waits for core's cheap status check to say the
* host answers, so a whole fleet registering at once does not open a
* burst of SSH connections to hosts that are down.
*/
private scheduleInitialPoll(host: MetricsHost): void {
if (this.metrics.has(host.id) || this.initialRequested.has(host.id)) {
return;
}
this.initialRequested.add(host.id);
void this.state.initialLimiter
.run(async () => {
let status: PluginHostStatusEntry | null = this.ctx.hosts.status.get(
host.id,
);
if (!status) status = await this.ctx.hosts.status.check(host.id);
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
return;
}
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await this.state.metricsLimiter.run(() => this.poll(host));
} finally {
this.inFlight.delete(host.id);
}
})
.catch((error) => {
this.deps.log.error("Initial metrics polling failed", error, {
operation: "initial_metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.initialRequested.delete(host.id));
}
private schedulePoll(host: MetricsHost): void {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
void this.state.metricsLimiter
.run(() => this.poll(host))
.catch((error) => {
this.deps.log.error("Metrics polling failed", error, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.inFlight.delete(host.id));
}
/** One sample for one host. Public for tests. */
async poll(host: MetricsHost): Promise<void> {
const { state, log } = this.deps;
if (!this.polled.has(host.id)) return;
if (state.authFailures.shouldSkip(host.id)) return;
if (state.backoff.shouldSkip(host.id)) return;
const settings = await this.settingsFor(host.id);
if (!settings.metricsEnabled) return;
let authenticated = false;
try {
const metrics = await collectMetrics(this.deps, host, settings, () => {
authenticated = true;
});
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
this.ctx.hosts.status.reportLogin(host.id, { ok: true });
this.setCollectionState(host, "collecting");
await this.recordSample(host.id, metrics);
this.ctx.events.emit(TOPIC_SNAPSHOT, {
hostId: host.id,
ownerUserId: host.userId,
metrics,
} satisfies SnapshotPayload);
state.backoff.reset(host.id);
state.authFailures.reset(host.id);
} catch (error) {
const hostKeyChanged = isHostKeyVerificationError(error);
const message = error instanceof Error ? error.message : "";
const isAuthError = /authentication|permission denied/i.test(message);
if (!authenticated) {
this.ctx.hosts.status.reportLogin(host.id, {
ok: false,
hostKeyChanged,
});
}
this.setCollectionState(
host,
hostKeyChanged
? "host_key_changed"
: isAuthError
? "auth_failed"
: "unreachable",
);
if (isAuthError) {
// collectMetrics already recorded it; log the first occurrence only.
if (!state.authFailures.shouldSkip(host.id)) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
return;
}
if (hostKeyChanged) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
log.error("Stats collector host key verification failed", error, {
operation: "stats_host_key_verification_failed",
hostId: host.id,
});
return;
}
state.backoff.recordFailure(host.id);
// Only log when a new retry window opens, not on every skipped poll.
const backoff = state.backoff.getBackoffInfo(host.id);
if (backoff !== null && !backoff.includes("polling suspended")) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
}
}
private setCollectionState(host: MetricsHost, next: CollectionState): void {
const previous = this.collectionState.get(host.id) ?? null;
if (previous === next) return;
this.collectionState.set(host.id, next);
this.ctx.events.emit(TOPIC_STATUS, {
hostId: host.id,
ownerUserId: host.userId,
state: next,
previous,
} satisfies CollectionStatusPayload);
}
private async recordSample(
hostId: number,
metrics: CollectedMetrics,
): Promise<void> {
try {
const iface = metrics.network?.interfaces?.[0];
const bytes = (value: string | null | undefined) => {
const parsed = value ? parseInt(value, 10) : NaN;
return Number.isNaN(parsed) ? null : parsed;
};
const retention = Number(
await this.ctx.settings.get<number>("historyRetentionDays"),
);
await this.deps.repository.recordSample(
{
hostId,
cpuPercent: metrics.cpu?.percent ?? null,
memPercent: metrics.memory?.percent ?? null,
diskPercent: metrics.disk?.percent ?? null,
netRxBytes: bytes(iface?.rxBytes),
netTxBytes: bytes(iface?.txBytes),
},
Number.isInteger(retention) && retention >= 1
? Math.min(retention, 90)
: DEFAULT_RETENTION_DAYS,
);
} catch (error) {
this.deps.log.warn("Failed to write metrics history", {
operation: "insert_metrics_history",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -1,230 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../../../src/types/host-metrics.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
}
interface PrefHost {
id: number;
userId: string;
statsConfig?: unknown;
}
type HostMetricsPreferencesRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
export function registerHostMetricsPreferencesRoutes(
app: Express,
{
validateHostId,
fetchHostById,
parseStatsConfig,
canAccessHost,
}: HostMetricsPreferencesRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
* 404:
* description: Host not found.
*/
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const row =
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
userId,
hostId,
);
if (row?.layout) {
try {
const parsed = sanitizeLayout(JSON.parse(row.layout));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to default
}
}
const statsConfig = parseStatsConfig(host.statsConfig);
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
return res.json({ layout });
} catch (error) {
statsLogger.error("Failed to fetch host metrics preferences", {
operation: "host_metrics_prefs_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
});
/**
* @openapi
* /host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
app.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const layout = sanitizeLayout(req.body);
if (!layout) {
return res.status(400).json({ error: "Invalid layout" });
}
const now = new Date().toISOString();
const layoutJson = JSON.stringify(layout);
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
userId,
hostId,
layoutJson,
now,
);
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
// user owns. stats_config is plain JSON text (not an encrypted field).
if (host.userId === userId) {
try {
const current = parseStatsConfig(host.statsConfig);
const merged = {
...current,
enabledWidgets: deriveEnabledWidgets(layout.slots),
};
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
userId,
hostId,
JSON.stringify(merged),
);
} catch (syncErr) {
statsLogger.warn("Failed to sync enabledWidgets from layout", {
operation: "host_metrics_prefs_sync_widgets",
hostId,
error:
syncErr instanceof Error ? syncErr.message : String(syncErr),
});
}
}
return res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save host metrics preferences", {
operation: "host_metrics_prefs_save_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
}
@@ -0,0 +1,233 @@
import { and, asc, desc, eq, gte, lt, lte, notInArray } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import {
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
hostMetricsPreferences,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands tables back untyped, and the drizzle handle is the
// server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface HealthResultInput {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
export interface HealthHistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
detail: string | null;
}
export interface MetricsHistoryInput {
hostId: number;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
export interface MetricsHistoryRow {
ts: string;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
/** Timestamps are text in `YYYY-MM-DD HH:MM:SS` UTC, which sorts in time order. */
export function sqlTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
export type HostMetricsRepository = Awaited<
ReturnType<typeof createHostMetricsRepository>
>;
/**
* Written without RETURNING so it runs the same on all three engines: an
* upsert reads the row by its unique (user, host) pair first.
*/
export async function createHostMetricsRepository(db: PluginDatabase) {
const preferences: Table = await db.define(hostMetricsPreferences);
const checks: Table = await db.define(hostHealthChecks);
const health: Table = await db.define(hostHealthHistory);
const history: Table = await db.define(hostMetricsHistory);
const client = () => db.client<Drizzle>();
async function findOne(table: Table, userId: string, hostId: number) {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.limit(1);
return rows[0] ?? null;
}
async function upsert(
table: Table,
userId: string,
hostId: number,
values: Record<string, unknown>,
) {
const drizzle = await client();
const now = sqlTimestamp(new Date());
const existing = await findOne(table, userId, hostId);
if (existing) {
await drizzle
.update(table)
.set({ ...values, updatedAt: now })
.where(eq(table.id, existing.id));
} else {
await drizzle.insert(table).values({
...values,
userId,
hostId,
createdAt: now,
updatedAt: now,
});
}
await db.persist();
}
return {
async findLayout(userId: string, hostId: number): Promise<string | null> {
return (await findOne(preferences, userId, hostId))?.layout ?? null;
},
saveLayout(userId: string, hostId: number, layout: string) {
return upsert(preferences, userId, hostId, { layout });
},
async findChecks(
userId: string,
hostId: number,
): Promise<{ checks: string; intervalSeconds: number } | null> {
return findOne(checks, userId, hostId);
},
saveChecks(
userId: string,
hostId: number,
json: string,
intervalSeconds: number,
) {
return upsert(checks, userId, hostId, { checks: json, intervalSeconds });
},
/** Adds results and keeps only the newest `keep` rows for the host. */
async recordHealth(
userId: string,
hostId: number,
results: HealthResultInput[],
keep: number,
): Promise<void> {
if (results.length === 0) return;
const drizzle = await client();
const ts = sqlTimestamp(new Date());
await drizzle
.insert(health)
.values(results.map((result) => ({ ...result, userId, hostId, ts })));
const scope = and(eq(health.userId, userId), eq(health.hostId, hostId));
const retained = await drizzle
.select({ id: health.id })
.from(health)
.where(scope)
.orderBy(desc(health.ts), desc(health.id))
.limit(keep);
await drizzle.delete(health).where(
retained.length
? and(
scope,
notInArray(
health.id,
retained.map((row: { id: number }) => row.id),
),
)
: scope,
);
await db.persist();
},
async listHealth(
userId: string,
hostId: number,
limit: number,
): Promise<HealthHistoryRow[]> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(health)
.where(and(eq(health.userId, userId), eq(health.hostId, hostId)))
.orderBy(desc(health.ts), desc(health.id))
.limit(limit);
return rows.map((row: Record<string, unknown>) => ({
checkId: row.checkId as string,
ts: row.ts as string,
ok: row.ok === true || row.ok === 1,
latencyMs: (row.latencyMs as number | null) ?? null,
detail: (row.detail as string | null) ?? null,
}));
},
/** One sample, then drops the host's samples older than the retention. */
async recordSample(
input: MetricsHistoryInput,
retentionDays: number,
): Promise<void> {
const drizzle = await client();
const now = new Date();
await drizzle.insert(history).values({ ...input, ts: sqlTimestamp(now) });
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
await drizzle
.delete(history)
.where(
and(
eq(history.hostId, input.hostId),
lt(history.ts, sqlTimestamp(cutoff)),
),
);
// A sample every few seconds per host: let the debounced save pick it up.
await db.persist({ lazy: true });
},
async listSamples(
hostId: number,
fromTs: string,
toTs: string,
): Promise<MetricsHistoryRow[]> {
const drizzle = await client();
return drizzle
.select({
ts: history.ts,
cpuPercent: history.cpuPercent,
memPercent: history.memPercent,
diskPercent: history.diskPercent,
netRxBytes: history.netRxBytes,
netTxBytes: history.netTxBytes,
})
.from(history)
.where(
and(
eq(history.hostId, hostId),
gte(history.ts, fromTs),
lte(history.ts, toTs),
),
)
.orderBy(asc(history.ts));
},
};
}
File diff suppressed because it is too large. Load diff
+105 -45
View File
@@ -1,16 +1,19 @@
import type { Client, ConnectConfig } from "ssh2";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
/** A connection a person opened from the tab, reused by polling. */
export interface MetricsSession {
client: Client;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId: number;
userId: string;
cancelCleanup?: () => void;
}
/** A connection waiting for the TOTP code the person is typing. */
export interface PendingTOTPSession {
client: Client;
finish: (responses: string[]) => void;
@@ -32,49 +35,106 @@ export interface MetricsViewer {
lastHeartbeat: number;
}
export const metricsSessions: Record<string, MetricsSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const IDLE_MS = 30 * 60 * 1000;
export function cleanupMetricsSession(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
statsLogger.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleMetricsSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete metricsSessions[sessionId];
}
}
export function scheduleMetricsSessionCleanup(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => {
cleanupMetricsSession(sessionId);
},
30 * 60 * 1000,
);
}
}
export function getSessionKey(hostId: number, userId: string): string {
export function sessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
function endQuietly(client: Client): void {
try {
client.end();
} catch {
// expected
}
}
/** Interactive connections, owned by one activation of the plugin. */
export class MetricsSessions {
private readonly sessions = new Map<string, MetricsSession>();
private readonly pending = new Map<string, PendingTOTPSession>();
constructor(
private readonly schedule: PluginSchedule,
private readonly log: MetricsLogger,
) {}
get(key: string): MetricsSession | undefined {
return this.sessions.get(key);
}
/** Stores a ready connection and closes it after 30 idle minutes. */
open(key: string, session: Omit<MetricsSession, "cancelCleanup">): void {
const existing = this.sessions.get(key);
if (existing && existing.client !== session.client) this.close(key);
this.sessions.set(key, { ...session });
this.scheduleCleanup(key);
session.client.once("close", () => {
const current = this.sessions.get(key);
if (current?.client === session.client) {
current.isConnected = false;
current.cancelCleanup?.();
this.sessions.delete(key);
}
});
}
touch(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.lastActive = Date.now();
this.scheduleCleanup(key);
}
/** Closes a session, or waits for the operations still using it. */
close(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
if (session.activeOperations > 0) {
this.log.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{ operation: "cleanup_deferred", sessionKey: key },
);
this.scheduleCleanup(key);
return;
}
session.cancelCleanup?.();
this.sessions.delete(key);
endQuietly(session.client);
}
addPending(session: PendingTOTPSession): void {
this.pending.set(session.sessionId, session);
}
getPending(sessionId: string): PendingTOTPSession | undefined {
return this.pending.get(sessionId);
}
dropPending(sessionId: string, endClient = false): void {
const session = this.pending.get(sessionId);
this.pending.delete(sessionId);
if (session && endClient) endQuietly(session.client);
}
dispose(): void {
for (const key of [...this.sessions.keys()]) {
const session = this.sessions.get(key)!;
session.cancelCleanup?.();
endQuietly(session.client);
}
this.sessions.clear();
for (const session of this.pending.values()) endQuietly(session.client);
this.pending.clear();
}
private scheduleCleanup(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.cancelCleanup?.();
session.cancelCleanup = this.schedule.after(IDLE_MS, () => {
session.cancelCleanup = undefined;
this.close(key);
});
}
}
@@ -1,256 +0,0 @@
import type { Express, RequestHandler } from "express";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
metricsInterval: number;
};
type HostMetricsSettingsRoutesDeps = {
requireAdmin: RequestHandler;
defaultStatsConfig: HostMetricsSettingsConfig;
refreshAllPolling: () => Promise<void>;
};
function isMissingSettingsTable(error: unknown): boolean {
return (
error instanceof Error && error.message.includes("no such table: settings")
);
}
export function registerHostMetricsSettingsRoutes(
app: Express,
{
requireAdmin,
defaultStatsConfig: DEFAULT_STATS_CONFIG,
refreshAllPolling,
}: HostMetricsSettingsRoutesDeps,
): void {
/**
* @openapi
* /global-settings:
* get:
* summary: Get global monitoring defaults
* tags:
* - Host Metrics
* responses:
* 200:
* description: Global monitoring settings.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings", requireAdmin, async (_req, res) => {
try {
const settings = createCurrentSettingsRepository();
const statusValue = await settings.get("global_status_check_interval");
const metricsValue = await settings.get("global_metrics_interval");
res.json({
statusCheckInterval: statusValue
? parseInt(statusValue, 10)
: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: metricsValue
? parseInt(metricsValue, 10)
: DEFAULT_STATS_CONFIG.metricsInterval,
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.warn("Settings table does not exist, using defaults", {
operation: "global_settings_table_check",
error: error.message,
});
return res.json({
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
});
}
statsLogger.error("Failed to fetch global settings", {
operation: "global_settings_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch global settings" });
}
});
/**
* @openapi
* /global-settings:
* post:
* summary: Update global monitoring defaults
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* metricsInterval:
* type: integer
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid parameters.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings", requireAdmin, async (req, res) => {
const { statusCheckInterval, metricsInterval } = req.body;
if (
statusCheckInterval !== undefined &&
(typeof statusCheckInterval !== "number" ||
statusCheckInterval < 5 ||
statusCheckInterval > 3600)
) {
return res.status(400).json({
error: "statusCheckInterval must be between 5 and 3600 seconds",
});
}
if (
metricsInterval !== undefined &&
(typeof metricsInterval !== "number" ||
metricsInterval < 5 ||
metricsInterval > 3600)
) {
return res
.status(400)
.json({ error: "metricsInterval must be between 5 and 3600 seconds" });
}
try {
const settings = createCurrentSettingsRepository();
if (statusCheckInterval !== undefined) {
await settings.set(
"global_status_check_interval",
String(statusCheckInterval),
);
}
if (metricsInterval !== undefined) {
await settings.set("global_metrics_interval", String(metricsInterval));
}
await refreshAllPolling();
res.json({
success: true,
message: "Settings updated and polling refreshed",
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.error(
"Settings table does not exist, cannot save settings",
{
operation: "global_settings_table_check",
error: error.message,
},
);
return res.status(500).json({
error:
"Database settings table is missing. Please check database initialization.",
});
}
statsLogger.error("Failed to save global settings", {
operation: "global_settings_save_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: "Failed to save global settings",
details: error instanceof Error ? error.message : String(error),
});
}
});
/**
* @openapi
* /global-settings/history:
* get:
* summary: Get metrics history retention setting
* tags:
* - Host Metrics
* responses:
* 200:
* description: Retention setting in days.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings/history", requireAdmin, async (_req, res) => {
try {
const value = await createCurrentSettingsRepository().get(
"metrics_history_retention_days",
);
const days = value ? parseInt(value, 10) : 7;
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
} catch (error) {
statsLogger.error("Failed to fetch history retention setting", {
operation: "history_retention_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch history retention setting" });
}
});
/**
* @openapi
* /global-settings/history:
* post:
* summary: Update metrics history retention setting
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* metricsHistoryRetentionDays:
* type: integer
* minimum: 1
* maximum: 90
* responses:
* 200:
* description: Setting saved.
* 400:
* description: Invalid value.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings/history", requireAdmin, async (req, res) => {
const { metricsHistoryRetentionDays } = req.body;
if (
typeof metricsHistoryRetentionDays !== "number" ||
metricsHistoryRetentionDays < 1 ||
metricsHistoryRetentionDays > 90
) {
return res.status(400).json({
error: "metricsHistoryRetentionDays must be between 1 and 90",
});
}
try {
await createCurrentSettingsRepository().set(
"metrics_history_retention_days",
String(Math.floor(metricsHistoryRetentionDays)),
);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save history retention setting", {
operation: "history_retention_save_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to save history retention setting" });
}
});
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+439
View File
@@ -0,0 +1,439 @@
export type HostStatus = "online" | "reachable" | "offline";
export class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>();
private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => {
try {
const result = await Promise.race<T>([
request(),
new Promise<never>((_, rej) =>
setTimeout(
() =>
rej(
new Error(
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
),
),
this.requestTimeout,
),
),
]);
resolve(result);
} catch (error) {
reject(error);
}
};
const queue = this.queues.get(hostId) || [];
queue.push(wrappedRequest);
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// expected
}
}
}
this.processing.delete(hostId);
const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: unknown;
timestamp: number;
hostId: number;
}
export class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): unknown | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: unknown): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
permanent: boolean;
}
export class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 5000;
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
if (record.reason === "TOTP" || record.permanent) {
return true;
}
if (record.count >= this.maxRetries) {
return true;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.reason === "HOST_KEY") {
return "SSH host key changed (verification required)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
resetHostKeyFailure(hostId: number): void {
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
export class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000;
private maxDelay = 600000;
private maxRetries = 5;
recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
const delay = Math.min(
this.baseDelay * Math.pow(2, existing.count),
this.maxDelay,
);
this.failures.set(hostId, {
count: existing.count + 1,
nextRetry: Date.now() + delay,
});
}
shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId);
if (!backoff) return false;
if (backoff.count >= this.maxRetries) {
return true;
}
return Date.now() < backoff.nextRetry;
}
getBackoffInfo(hostId: number): string | null {
const backoff = this.failures.get(hostId);
if (!backoff) return null;
if (backoff.count >= this.maxRetries) {
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
}
const remainingMs = backoff.nextRetry - Date.now();
if (remainingMs > 0) {
const remainingSec = Math.ceil(remainingMs / 1000);
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) {
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId);
}
}
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
get limit(): number {
return this.maxConcurrent;
}
/**
* Changes the ceiling at runtime.
*
* Raising it wakes the waiters the new headroom allows, so a queue that built
* up under the old limit drains at once instead of one job at a time as
* running work finishes. Lowering it never interrupts work already running;
* the new limit simply applies from the next release onward.
*/
setLimit(maxConcurrent: number): void {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
this.maxConcurrent = maxConcurrent;
this.releaseWaiters();
}
/**
* Wakes as many queued callers as there is now room for.
*
* `woken` counts callers that have been resumed but have not yet reached the
* `active += 1` on the far side of their await. Without it the occupancy
* looks lower than it really is for a microtask, and the loop would release
* past the ceiling.
*/
private releaseWaiters(): void {
while (
this.active + this.woken < this.maxConcurrent &&
this.waiters.length > 0
) {
this.woken += 1;
this.waiters.shift()!();
}
}
private woken = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
// Queue behind anything already woken, so a caller arriving mid-handoff
// cannot jump the queue and push occupancy past the ceiling.
if (this.active + this.woken >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
// Resumed by a slot handoff; that reservation is now consumed.
this.woken = Math.max(0, this.woken - 1);
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.releaseWaiters();
}
}
}
/**
* How many metrics polls may run at once for a given number of polled hosts.
*
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
* interval, so polling fell permanently behind and a host could wait over a
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
* its interval; the cap keeps file descriptors and CPU bounded.
*/
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
const MIN_METRICS_CONCURRENCY = 5;
const MAX_METRICS_CONCURRENCY = 50;
/** Aim to spend about a twentieth of the interval per sweep wave. */
const HOSTS_PER_WORKER = 20;
export function metricsConcurrencyFor(
hostCount: number,
env: NodeJS.ProcessEnv = process.env,
): number {
const override = Number(env[METRICS_CONCURRENCY_ENV]);
if (Number.isFinite(override) && override >= 1) {
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
}
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
return Math.min(
Math.max(scaled, MIN_METRICS_CONCURRENCY),
MAX_METRICS_CONCURRENCY,
);
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/**
* The first heavy SSH sample waits until core's cheap status check says the
* host answers. A host core does not check (status checks off) is tried.
*/
export function canStartInitialMetrics(
status: HostStatus | null | undefined,
hasViewers: boolean,
): boolean {
return hasViewers && status !== "offline";
}
/** Everything one activation of the plugin polls with. */
export function createMetricsState() {
return {
/** SSH metrics execs are expensive; resized with the fleet. */
metricsLimiter: new ConcurrentLimiter(5),
/** Viewer registration is bursty; admit only two first samples at a time. */
initialLimiter: new ConcurrentLimiter(2),
hostCache: new HostPollCache(30_000),
requestQueue: new RequestQueue(),
metricsCache: new MetricsCache(),
authFailures: new AuthFailureTracker(),
backoff: new PollingBackoff(),
};
}
export type MetricsState = ReturnType<typeof createMetricsState>;
+120
View File
@@ -0,0 +1,120 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
real,
refHost,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/** One card layout per user per host, as JSON. */
export const hostMetricsPreferences = adoptLegacyTable(
"host_metrics_preferences",
defineTable(
"host_metrics_preferences",
{
id: id(),
userId: refUser(),
hostId: refHost(),
layout: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_metrics_prefs_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
/** A user's health checks for a host, as a JSON array. */
export const hostHealthChecks = adoptLegacyTable(
"host_health_checks",
defineTable(
"host_health_checks",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checks: text().notNull(),
intervalSeconds: integer().notNull().default(300),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_health_checks_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
export const hostHealthHistory = adoptLegacyTable(
"host_health_history",
defineTable(
"host_health_history",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checkId: varchar().notNull(),
ts: timestamp().notNull().defaultNow(),
ok: boolean().notNull(),
latencyMs: integer(),
detail: text(),
},
{
indexes: [
{
name: "idx_host_health_history_lookup",
columns: ["userId", "hostId", "checkId", "ts"],
},
],
},
),
);
/** One row per metrics sample, pruned to the retention setting. */
export const hostMetricsHistory = adoptLegacyTable(
"host_metrics_history",
defineTable(
"host_metrics_history",
{
id: id(),
hostId: refHost(),
ts: timestamp().notNull().defaultNow(),
cpuPercent: real(),
memPercent: real(),
diskPercent: real(),
netRxBytes: integer(),
netTxBytes: integer(),
},
{
indexes: [
{
name: "idx_host_metrics_history_host_ts",
columns: ["hostId", "ts"],
},
],
},
),
);
export const tables = [
hostMetricsPreferences,
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
];
+17
View File
@@ -0,0 +1,17 @@
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
@@ -1,291 +0,0 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
type ViewerStatsConfig = {
metricsEnabled: boolean;
};
type HostMetricsViewerRoutesDeps<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
> = {
fetchHostById: (hostId: number, userId: string) => Promise<THost>;
supportsMetrics: (host: THost) => boolean;
parseStatsConfig: (statsConfig: THost["statsConfig"]) => TStatsConfig;
updateHeartbeat: (viewerSessionId: string) => boolean;
registerViewer: (
hostId: number,
viewerSessionId: string,
userId: string,
) => void;
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
/** Route path segment, e.g. "metrics" -> /metrics/heartbeat. Defaults to "metrics". */
pathPrefix?: string;
};
export function registerHostMetricsViewerRoutes<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
>(
app: Express,
{
fetchHostById,
supportsMetrics,
parseStatsConfig,
updateHeartbeat,
registerViewer,
unregisterViewer,
pathPrefix = "metrics",
}: HostMetricsViewerRoutesDeps<THost, TStatsConfig>,
): void {
/**
* @openapi
* /metrics/heartbeat:
* post:
* summary: Update viewer heartbeat
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Heartbeat updated successfully.
* 400:
* description: Invalid viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Viewer session not found.
* 500:
* description: Failed to update heartbeat.
*/
app.post(`/${pathPrefix}/heartbeat`, async (req, res) => {
const { viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
const success = updateHeartbeat(viewerSessionId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Viewer session not found" });
}
} catch (error) {
statsLogger.error("Failed to update heartbeat", {
operation: "heartbeat_error",
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to update heartbeat" });
}
});
/**
* @openapi
* /metrics/register-viewer:
* post:
* summary: Register metrics viewer
* description: Registers a new viewer session for a host to track who is viewing metrics.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Viewer registered successfully.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to register viewer.
*/
app.post(`/${pathPrefix}/register-viewer`, async (req, res) => {
const { hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
// Graceful no-op if host is inaccessible, metrics disabled, or host type
// does not support metrics. The client may call this speculatively, so
// avoid returning 5xx for expected "no metrics available" scenarios.
let host: THost | undefined;
try {
host = await fetchHostById(hostId, userId);
} catch (lookupErr) {
statsLogger.warn(
"register-viewer host lookup failed (treating as no-op)",
{
operation: "register_viewer_lookup",
hostId,
userId,
error: getErrorMessage(lookupErr, String(lookupErr)),
},
);
}
if (!host) {
return res.json({
success: true,
skipped: true,
reason: "host_not_found",
});
}
if (!supportsMetrics(host)) {
return res.json({
success: true,
skipped: true,
reason: "metrics_unsupported",
});
}
const statsConfig = parseStatsConfig(host.statsConfig);
if (!statsConfig.metricsEnabled) {
return res.json({
success: true,
skipped: true,
reason: "metrics_disabled",
});
}
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
registerViewer(hostId, viewerSessionId, userId);
} catch (regErr) {
statsLogger.warn(
"pollingManager.registerViewer threw (treating as no-op)",
{
operation: "register_viewer_internal",
hostId,
userId,
error: regErr instanceof Error ? regErr.message : String(regErr),
},
);
return res.json({
success: true,
skipped: true,
reason: "register_failed_noop",
});
}
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
userId,
error: error instanceof Error ? error.message : String(error),
});
// Even on unexpected errors we prefer a graceful client experience: the
// viewer-registration is purely an optimization and should never break
// the UI. Report success:false but HTTP 200 so the client can decide.
res.status(200).json({
success: false,
skipped: true,
reason: "internal_error",
});
}
});
/**
* @openapi
* /metrics/unregister-viewer:
* post:
* summary: Unregister metrics viewer
* description: Unregisters a viewer session when they stop viewing metrics for a host.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Viewer unregistered successfully.
* 400:
* description: Invalid hostId or viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to unregister viewer.
*/
app.post(`/${pathPrefix}/unregister-viewer`, async (req, res) => {
const { hostId, viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
unregisterViewer(hostId, viewerSessionId);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to unregister viewer", {
operation: "unregister_viewer_error",
hostId,
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to unregister viewer" });
}
});
}
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
/**
* CPU percent is derived from the delta against the previous poll's
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
@@ -1,10 +1,10 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../../../src/types/stats-widgets.js";
} from "../../shared/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
@@ -1,8 +1,6 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface GpuDevice {
index: number;
@@ -1,5 +1,5 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface LoginRecord {
user: string;
@@ -1,11 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
kibToGiB,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum, kibToGiB } from "../util.js";
import type { Client } from "ssh2";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
export interface NetworkCounters {
rx: string;
@@ -1,9 +1,9 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
PortsMetrics,
ListeningPort,
} from "../../../../../src/types/stats-widgets.js";
} from "../../shared/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
@@ -1,5 +1,5 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
@@ -1,8 +1,6 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface TemperatureSensor {
label: string;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);
@@ -1,7 +1,18 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Activity,
Button,
Input,
SectionCard,
SettingRow,
FakeSwitch,
Select2,
} from "@termix/plugin-sdk/ui";
import { useEffect, useState } from "react";
import {
usePluginApi,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
HardDrive,
LayoutDashboard,
Plus,
@@ -10,32 +21,66 @@ import {
Zap,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
const PLUGIN_ID = "host-metrics";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
type QuickActionRow = { name: string; snippetId: string };
/**
* The Host Metrics section of the host editor. Its values are this plugin's
* host settings, carried on form.pluginSettings and saved by the editor after
* the host itself. Quick actions are a core host field.
*/
export function HostStatsTab({
form,
setField,
updateForm,
host,
snippets,
}: {
form: HostEditorForm;
setField: SetHostField;
snippets: { id: number; name: string }[];
}) {
}: HostEditorSectionProps) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const excludedMounts = form.statsConfig.excludedMounts ?? [];
const monitoredMounts = form.statsConfig.monitoredMounts ?? [];
const bag = (form.pluginSettings as PluginSettingsBag | undefined)?.[
PLUGIN_ID
];
const settings = readHostMetricsSettings(bag);
const { excludedMounts, monitoredMounts } = settings;
const quickActions = (form.quickActions ?? []) as QuickActionRow[];
const snippetOptions = (snippets ?? []) as { id: number; name: string }[];
const api = usePluginApi();
const patch = (values: Record<string, unknown>) =>
updateForm((current) => {
const all =
(current.pluginSettings as PluginSettingsBag | undefined) ?? {};
return {
pluginSettings: {
...all,
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
},
};
});
// A new host starts with the admin's default for metrics.
useEffect(() => {
if (host || bag?.metricsEnabled !== undefined) return;
let cancelled = false;
api
.get<{ enabledForNewHosts: boolean }>("/defaults")
.then(({ data }) => {
if (!cancelled && data.enabledForNewHosts === false) {
patch({ metricsEnabled: false });
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [host]);
const addExcludedMount = () => {
const value = newMount.trim();
@@ -43,18 +88,14 @@ export function HostStatsTab({
setNewMount("");
return;
}
setField("statsConfig", {
...form.statsConfig,
excludedMounts: [...excludedMounts, value],
});
patch({ excludedMounts: [...excludedMounts, value] });
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
setField("statsConfig", {
...form.statsConfig,
patch({
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
@@ -66,62 +107,6 @@ export function HostStatsTab({
return (
<>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
@@ -132,50 +117,36 @@ export function HostStatsTab({
description={t("hosts.enableMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.metricsEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
metricsEnabled: v,
})
}
checked={settings.metricsEnabled}
onChange={(v) => patch({ metricsEnabled: v })}
/>
</SettingRow>
{form.statsConfig.metricsEnabled && (
{settings.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
checked={settings.metricsInterval === null}
onChange={(v) => patch({ metricsInterval: v ? null : 30 })}
/>
</SettingRow>
)}
{settings.metricsEnabled && settings.metricsInterval !== null && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={settings.metricsInterval ?? 30}
onChange={(e) =>
patch({ metricsInterval: Number(e.target.value) })
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
{form.statsConfig.metricsEnabled &&
!form.statsConfig.useGlobalMetricsInterval && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={form.statsConfig.metricsInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
metricsInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
@@ -236,8 +207,7 @@ export function HostStatsTab({
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
patch({
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
@@ -295,8 +265,7 @@ export function HostStatsTab({
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
patch({
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
@@ -329,7 +298,7 @@ export function HostStatsTab({
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("quickActions", [
...form.quickActions,
...quickActions,
{ name: "", snippetId: "" },
])
}
@@ -342,13 +311,13 @@ export function HostStatsTab({
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</p>
{form.quickActions.length === 0 && (
{quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("hosts.noQuickActions")}</span>
</div>
)}
{form.quickActions.map((a, i) => (
{quickActions.map((a, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
@@ -358,7 +327,7 @@ export function HostStatsTab({
placeholder={t("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...form.quickActions];
const updated = [...quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
@@ -367,7 +336,7 @@ export function HostStatsTab({
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...form.quickActions];
const updated = [...quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
@@ -376,7 +345,7 @@ export function HostStatsTab({
}}
>
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
{snippets.map((s) => (
{snippetOptions.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
@@ -387,7 +356,7 @@ export function HostStatsTab({
onClick={() =>
setField(
"quickActions",
form.quickActions.filter((_, idx) => idx !== i),
quickActions.filter((_, idx) => idx !== i),
)
}
>
@@ -1,8 +1,7 @@
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { HostMetricsTab } from "./HostMetricsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface HostMetricsAppProps {
hostId?: string;
@@ -1,25 +1,40 @@
import { getErrorMessage } from "@/lib/error-message.js";
import {
Separator,
Button,
useAreaPreferences,
useConfirmation,
TOTPDialog,
useTabsSafe,
ConnectionLogProvider,
useConnectionLog,
ConnectionScreen,
runAdaptivePolling,
CardGridCanvas,
ColumnCountStepper,
type GridCardCatalogEntry,
logActivity,
SnippetVariablesDialog,
} from "@termix/plugin-sdk/ui";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../shared/host-metrics.js";
import type { ServerMetrics } from "../shared/metrics.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Button } from "@/components/button.tsx";
import {
getServerStatusById,
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
submitMetricsTOTP,
executeSnippet,
getSnippets,
logActivity,
sendMetricsHeartbeat,
getSSHHosts,
type ServerMetrics,
} from "@/main-axios.ts";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import type { Snippet } from "@/types/ui-types.ts";
import { useHostMetricsApi } from "./host-metrics-api";
interface Snippet {
id: number;
name: string;
content: string;
}
function errorText(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return typeof error === "string" ? error : "";
}
// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for
// whether a snippet still has $INPUT_n placeholders once host variables are
@@ -30,35 +45,16 @@ function hasSnippetInputs(content: string): boolean {
SNIPPET_INPUT_PATTERN.lastIndex = 0;
return SNIPPET_INPUT_PATTERN.test(content);
}
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import {
useTranslation,
useSlotContributions,
useConnectionRetry,
useHost,
useHostStatus,
invokeAction,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets.ts";
import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "@/types/host-metrics.ts";
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
import type { LogEntry } from "@/types/connection-log.ts";
import {
CardGridCanvas,
ColumnCountStepper,
} from "@/components/card-grid/CardGridCanvas.tsx";
import type { GridCardCatalogEntry } from "@/components/card-grid/types.ts";
import { useHostMetricsPreferences } from "./hooks/useHostMetricsPreferences.ts";
import {
CARD_DEFINITIONS,
@@ -79,7 +75,9 @@ interface QuickAction {
snippetId: number;
}
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
type ConnectionLogPayload = Parameters<
ReturnType<typeof useConnectionLog>["addLog"]
>[0];
type ConnectionLogError = Error & {
connectionLogs?: ConnectionLogPayload[];
};
@@ -90,7 +88,8 @@ interface HostConfig {
ip: string;
username: string;
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
statusCheckEnabled?: boolean;
pluginSettings?: Record<string, Record<string, unknown>>;
authType?: string;
port?: number;
[key: string]: unknown;
@@ -104,16 +103,6 @@ interface HostMetricsProps {
embedded?: boolean;
}
function parseStatsConfig(raw?: string | StatsConfig): StatsConfig {
if (!raw) return DEFAULT_STATS_CONFIG;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch {
return DEFAULT_STATS_CONFIG;
}
}
function HostMetricsInner({
hostConfig,
title,
@@ -122,6 +111,7 @@ function HostMetricsInner({
embedded = false,
}: HostMetricsProps): React.ReactElement {
const { t } = useTranslation();
const metricsApi = useHostMetricsApi();
const { addLog, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
@@ -157,14 +147,16 @@ function HostMetricsInner({
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const snippetsCacheRef = React.useRef<Snippet[] | null>(null);
const snippetsCacheRef = React.useRef(new Map<number, Snippet>());
const statsConfig = React.useMemo(
() => parseStatsConfig(currentHostConfig?.statsConfig),
[currentHostConfig?.statsConfig],
);
const statsConfig = React.useMemo(() => {
const settings = readHostMetricsSettings(
currentHostConfig?.pluginSettings?.["host-metrics"],
);
return { ...settings, metricsInterval: settings.metricsInterval ?? 30 };
}, [currentHostConfig?.pluginSettings]);
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const statusCheckEnabled = currentHostConfig?.statusCheckEnabled !== false;
const hostId = currentHostConfig?.id ?? null;
const { layout, setLayout } = useHostMetricsPreferences(hostId);
@@ -245,7 +237,7 @@ function HostMetricsInner({
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const interval = setInterval(() => {
sendMetricsHeartbeat(viewerSessionId).catch(() => {});
metricsApi.heartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
@@ -297,7 +289,7 @@ function HostMetricsInner({
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await submitMetricsTOTP(totpSessionId, totpCode);
const result = await metricsApi.submitTotp(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
setTotpSessionId(null);
@@ -314,66 +306,33 @@ function HostMetricsInner({
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
await stopMetricsPolling(currentHostConfig.id).catch(() => {});
await metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
}
if (currentTab !== null) removeTab(currentTab);
};
// The shell keeps the host list current, including plugin settings.
const latestHost = useHost(hostConfig?.id);
React.useEffect(() => {
const fetchLatest = async () => {
if (!hostConfig?.id) return;
try {
const hosts = await getSSHHosts();
const updated = hosts.find((h) => h.id === hostConfig.id);
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
} catch {
toast.error(t("hostMetrics.failedToFetchHostConfig"));
}
};
fetchLatest();
const onChanged = () => fetchLatest();
window.addEventListener("ssh-hosts:changed", onChanged);
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
}, [hostConfig?.id]);
if (latestHost) {
setCurrentHostConfig({
...(latestHost as unknown as HostConfig),
id: Number(latestHost.id),
});
}
}, [latestHost]);
// Core's status check, the same one behind the host list's dot.
const hostStatus = useHostStatus(hostId ?? undefined);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id || !isActuallyVisible) {
if (!statusCheckEnabled) setServerStatus("offline");
if (!statusCheckEnabled) {
setServerStatus("offline");
return;
}
let cancelled = false;
let lastStatus = serverStatus;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig.id);
const nextStatus = res?.status === "online" ? "online" : "offline";
if (!cancelled) {
setServerStatus(nextStatus);
}
const changed = nextStatus !== lastStatus;
lastStatus = nextStatus;
return changed;
} catch {
if (!cancelled) setServerStatus("offline");
throw new Error("Host status check failed");
}
};
const minIntervalMs = statsConfig.statusCheckInterval * 1000;
const stop = runAdaptivePolling(fetchStatus, {
minIntervalMs,
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
stablePollsPerStep: 3,
});
return () => {
cancelled = true;
stop();
};
}, [
currentHostConfig?.id,
statusCheckEnabled,
statsConfig.statusCheckInterval,
isActuallyVisible,
]);
if (hostStatus) {
setServerStatus(hostStatus.status === "online" ? "online" : "offline");
}
}, [hostStatus?.status, statusCheckEnabled]);
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
@@ -391,7 +350,7 @@ function HostMetricsInner({
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
const result = await metricsApi.startMetrics(currentHostConfig.id);
result?.connectionLogs?.forEach((log) =>
addLog(log as ConnectionLogPayload),
);
@@ -409,10 +368,10 @@ function HostMetricsInner({
// collected asynchronously on the backend (status check, then metrics
// exec), so it isn't ready the instant the connection succeeds. Give it
// a few short retries before treating the initial fetch as a failure.
let data = await getServerMetricsById(currentHostConfig.id);
let data = await metricsApi.getMetrics(currentHostConfig.id);
for (let i = 0; !data && i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
data = await getServerMetricsById(currentHostConfig.id);
data = await metricsApi.getMetrics(currentHostConfig.id);
}
if (!data) {
throw new Error(t("hostMetrics.connectionFailed"));
@@ -433,7 +392,7 @@ function HostMetricsInner({
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = runAdaptivePolling(
async () => {
const next = await getServerMetricsById(currentHostConfig.id);
const next = await metricsApi.getMetrics(currentHostConfig.id);
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
const nextSignature = metricsChangeKey(next);
const changed = nextSignature !== signature;
@@ -458,6 +417,7 @@ function HostMetricsInner({
currentTab,
removeTab,
pushHistory,
metricsApi,
]);
const metricsRetry = useConnectionRetry({
@@ -506,10 +466,9 @@ function HostMetricsInner({
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
).catch(() => {});
await metricsApi
.stopMetrics(currentHostConfig.id, viewerSessionId || undefined)
.catch(() => {});
}
};
@@ -530,7 +489,7 @@ function HostMetricsInner({
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, isPageVisible, metricsEnabled]);
@@ -559,9 +518,7 @@ function HostMetricsInner({
}
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === "online" ? "online" : "offline");
const data = await getServerMetricsById(currentHostConfig.id);
const data = await metricsApi.getMetrics(currentHostConfig.id);
if (data) {
setMetrics(data);
pushHistory(data);
@@ -586,12 +543,19 @@ function HostMetricsInner({
},
);
try {
const result = await executeSnippet(
const result = (await invokeAction(
"snippets.execute",
action.snippetId,
currentHostConfig.id,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
if (result.success) {
)) as { success: boolean; output?: string; error?: string } | undefined;
if (!result) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: t("hostMetrics.snippetsUnavailable"),
duration: 5000,
});
} else if (result.success) {
toast.success(
t("hostMetrics.quickActionSuccess", { name: action.name }),
{
@@ -610,7 +574,7 @@ function HostMetricsInner({
} catch (error) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: getErrorMessage(error),
description: errorText(error),
duration: 5000,
});
} finally {
@@ -645,13 +609,13 @@ function HostMetricsInner({
async function runQuickAction(action: QuickAction) {
if (!currentHostConfig) return;
try {
if (!snippetsCacheRef.current) {
snippetsCacheRef.current =
(await getSnippets()) as unknown as Snippet[];
let snippet = snippetsCacheRef.current.get(action.snippetId);
if (!snippet) {
snippet =
((await invokeAction("snippets.get", action.snippetId)) as
Snippet | null | undefined) ?? undefined;
if (snippet) snippetsCacheRef.current.set(snippet.id, snippet);
}
const snippet = snippetsCacheRef.current?.find(
(s) => s.id === action.snippetId,
);
if (snippet && hasSnippetInputs(snippet.content)) {
setRunningAction({ action, snippet });
return;
@@ -843,7 +807,11 @@ function HostMetricsInner({
{runningAction && (
<SnippetVariablesDialog
snippet={runningAction.snippet}
snippet={
runningAction.snippet as Parameters<
typeof SnippetVariablesDialog
>[0]["snippet"]
}
host={
currentHostConfig
? {
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import type {
MetricsChartConfig,
MetricsChartMetric,
MetricsChartRange,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { Select2 } from "@/components/select2";
} from "../shared/homepage.js";
const METRICS: { id: MetricsChartMetric; label: string }[] = [
{ id: "cpu", label: "CPU" },
@@ -29,13 +27,11 @@ export function MetricsChartEditForm({
onChange,
}: WidgetEditFormProps<MetricsChartConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
const { hosts: records } = useHosts();
const hosts = records.map((host) => ({
id: Number(host.id),
name: host.name,
}));
return (
<div className="flex flex-col gap-3">
@@ -1,18 +1,16 @@
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
import { useEffect, useState, useRef } from "react";
import { TrendingUp } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricsChartEditForm } from "./MetricsChartEditForm";
import type {
MetricsChartConfig,
MetricsChartMetric,
WidgetComponentProps,
WidgetTypeDefinition,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getMetricsHistory, type MetricsHistoryRow } from "./host-metrics-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
import { runVisibleInterval } from "@/features/homepage/use-visible-interval";
import {
GRID_SIZE,
type MetricsChartConfig,
type MetricsChartMetric,
type WidgetComponentProps,
type WidgetDefinition,
} from "../shared/homepage.js";
import { type MetricsHistoryRow, useHostMetricsApi } from "./host-metrics-api";
function getAccentColor(): string {
return (
@@ -119,24 +117,15 @@ function MetricsChartWidget({
widget,
config,
}: WidgetComponentProps<MetricsChartConfig>) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const { hostId, metric, range, showCurrentValue } = config;
const [hostName, setHostName] = useState<string | null>(null);
const hostName = useHost(hostId || undefined)?.name ?? null;
const [values, setValues] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 0, h: 0 });
useEffect(() => {
if (!hostId) return;
getSSHHosts()
.then((hosts) => {
const h = hosts.find((x) => x.id === hostId);
if (h) setHostName(h.name);
})
.catch(() => {});
}, [hostId]);
useEffect(() => {
if (!containerRef.current) return;
const obs = new ResizeObserver((entries) => {
@@ -233,7 +222,7 @@ function MetricsChartWidget({
}
/** Registered by the plugin while it runs. */
export const metricsChartWidget: WidgetTypeDefinition<MetricsChartConfig> = {
export const metricsChartWidget: WidgetDefinition<MetricsChartConfig> = {
id: "metrics_chart",
name: "Metrics Chart",
description: "Historical CPU, memory, disk, or network chart for a host",
@@ -1,73 +0,0 @@
import { useEffect, useState } from "react";
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
import { Button, Input } from "@termix/plugin-sdk/ui";
import {
getMetricsHistoryRetention,
saveMetricsHistoryRetention,
} from "./host-metrics-api";
/**
* How long metrics history is kept. Lives in the metrics backend's own
* setting, so it saves with its own button rather than the page's.
*/
export function MetricsRetentionSetting() {
const { t } = useTranslation();
const toast = useToast();
const [days, setDays] = useState("7");
useEffect(() => {
let cancelled = false;
getMetricsHistoryRetention()
.then((value) => {
if (!cancelled) setDays(String(value));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const save = async () => {
const value = Number.parseInt(days, 10);
if (Number.isNaN(value) || value < 1 || value > 90) {
toast.error(t("admin.metricsHistoryRetentionRange"));
return;
}
try {
await saveMetricsHistoryRetention(value);
toast.success(t("admin.monitoringSaved"));
} catch {
toast.error(t("admin.monitoringSaveFailed"));
}
};
return (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.metricsHistoryRetention")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={90}
value={days}
onChange={(event) => setDays(event.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("admin.days")}</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={() => void save()}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.metricsHistoryRetentionRange")}
</span>
</div>
);
}
@@ -0,0 +1,269 @@
import { usePermission, useTranslation } from "@termix/plugin-sdk/frontend";
import type { MetricsSummaryStore } from "./summary-store";
import { useMetricsSummary } from "./summary-store";
type MetricKey =
"cpu" | "memory" | "disk" | "uptime" | "system" | "network" | "processes";
function RowBar({ label, value }: { label: string; value: number }) {
const tone =
value >= 90
? ["bg-red-500", "text-red-400"]
: value >= 70
? ["bg-yellow-500", "text-yellow-400"]
: ["bg-accent-brand", "text-accent-brand"];
return (
<div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className={`text-[10px] font-bold ${tone[1]}`}>
{value.toFixed(0)}%
</span>
</div>
<div className="h-0.5 bg-muted w-full">
<div className={`h-full ${tone[0]}`} style={{ width: `${value}%` }} />
</div>
</div>
);
}
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostMetrics"). */
export function createDashboardHostMetrics(store: MetricsSummaryStore) {
return function DashboardHostMetrics(props: Record<string, unknown>) {
const { t } = useTranslation();
const allowed = usePermission("use");
const hostId = Number(props.hostId) || null;
const online = props.online === true;
const metrics = useMetricsSummary(store, allowed && online ? hostId : null);
const cpu = metrics?.cpu?.percent ?? null;
const ram = metrics?.memory?.percent ?? null;
const disk = metrics?.disk?.percent ?? null;
if (!allowed) return null;
if (!online || (cpu === null && ram === null && disk === null)) {
return (
<div className="flex items-center gap-3">
{[0, 1, 2].map((slot) => (
<span
key={slot}
className="text-[10px] text-muted-foreground w-16 text-center"
>
-
</span>
))}
</div>
);
}
return (
<div className="flex items-center gap-3">
{cpu !== null && <RowBar label={t("dashboard.cpu")} value={cpu} />}
{ram !== null && <RowBar label={t("dashboard.ram")} value={ram} />}
{disk !== null && (
<RowBar label={t("dashboardTab.disk")} value={disk} />
)}
</div>
);
};
}
function accentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function WidgetBar({
label,
value,
sublabel,
}: {
label: string;
value: number | null;
sublabel?: string;
}) {
const pct = value ?? 0;
const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : accentColor();
return (
<div className="flex flex-col gap-0.5">
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>{label}</span>
<span className="text-right">
{value != null ? `${Math.round(pct)}%` : "N/A"}
{sublabel && <span className="ml-1 opacity-60">{sublabel}</span>}
</span>
</div>
<div className="h-1 bg-muted overflow-hidden">
<div
className="h-full transition-all"
style={{ width: `${pct}%`, background: color }}
/>
</div>
</div>
);
}
function InfoRow({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
if (!value) return null;
return (
<div className="flex justify-between text-[10px] gap-2">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="text-foreground truncate text-right">{value}</span>
</div>
);
}
function formatBytes(n: number): string {
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${n} B`;
}
/** The numbers under core's homepage host status widget ("homepage.hostMetrics"). */
export function createHomepageHostMetrics(store: MetricsSummaryStore) {
return function HomepageHostMetrics(props: Record<string, unknown>) {
const { t } = useTranslation();
const allowed = usePermission("use");
const hostId = Number(props.hostId) || null;
const shown = (
Array.isArray(props.shownMetrics) ? props.shownMetrics : []
) as MetricKey[];
const online = props.online === true;
const metrics = useMetricsSummary(store, allowed ? hostId : null);
if (!allowed || shown.length === 0) return null;
const interfaces = metrics?.network?.interfaces ?? [];
const sum = (pick: (iface: (typeof interfaces)[number]) => unknown) =>
interfaces.reduce((total, iface) => {
const raw = pick(iface);
return raw ? total + parseFloat(String(raw)) : total;
}, 0);
const totalRx = sum((iface) => iface.rxBytes);
const totalTx = sum((iface) => iface.txBytes);
return (
<div className="flex flex-col gap-2">
{shown.includes("cpu") && (
<WidgetBar
label={t("homepage.metricCpu")}
value={metrics?.cpu?.percent ?? null}
sublabel={metrics?.cpu?.cores ? `${metrics.cpu.cores}c` : undefined}
/>
)}
{shown.includes("memory") && (
<WidgetBar
label={t("homepage.metricMemory")}
value={metrics?.memory?.percent ?? null}
sublabel={
metrics?.memory?.usedGiB != null &&
metrics?.memory?.totalGiB != null
? `${metrics.memory.usedGiB.toFixed(1)}/${metrics.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
/>
)}
{shown.includes("disk") && (
<WidgetBar
label={t("homepage.metricDisk")}
value={metrics?.disk?.percent ?? null}
sublabel={
metrics?.disk?.usedHuman && metrics?.disk?.totalHuman
? `${metrics.disk.usedHuman}/${metrics.disk.totalHuman}`
: undefined
}
/>
)}
{shown.includes("uptime") && (
<InfoRow
label={t("homepage.metricUptime")}
value={metrics?.uptime?.formatted ?? null}
/>
)}
{shown.includes("system") && metrics?.system && (
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
<InfoRow
label={t("homepage.metricOs")}
value={metrics.system.os ?? null}
/>
<InfoRow
label={t("homepage.metricKernel")}
value={metrics.system.kernel ?? null}
/>
<InfoRow
label={t("homepage.metricHostname")}
value={metrics.system.hostname ?? null}
/>
</div>
)}
{shown.includes("network") && interfaces.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
<div className="text-[10px] font-medium text-muted-foreground mb-0.5">
{t("homepage.metricNetwork")}
</div>
{interfaces.slice(0, 3).map((iface) => (
<div
key={iface.name}
className="flex justify-between text-[10px] gap-2"
>
<span className="text-muted-foreground shrink-0 truncate max-w-[60px]">
{iface.name}
</span>
<span className="text-foreground">
{iface.ip || iface.state}
</span>
</div>
))}
{(totalRx > 0 || totalTx > 0) && (
<div className="flex justify-between text-[10px] gap-2 mt-0.5">
<span className="text-muted-foreground">RX/TX</span>
<span className="text-foreground">
{formatBytes(totalRx)} / {formatBytes(totalTx)}
</span>
</div>
)}
</div>
)}
{shown.includes("processes") && metrics?.processes && (
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
<div className="flex justify-between text-[10px]">
<span className="text-muted-foreground">
{t("homepage.metricProcesses")}
</span>
<span className="text-foreground">
{metrics.processes.total ?? "?"}{" "}
{t("homepage.metricProcessesTotal")}
{metrics.processes.running != null
? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}`
: ""}
</span>
</div>
{metrics.processes.top?.slice(0, 3).map((proc) => (
<div
key={proc.pid}
className="flex justify-between text-[10px] gap-2"
>
<span className="text-muted-foreground truncate max-w-[80px]">
{proc.command}
</span>
<span className="text-foreground shrink-0">{proc.cpu}%</span>
</div>
))}
</div>
)}
{!metrics && online && (
<span className="text-[10px] text-muted-foreground/60 italic">
{t("homepage.metricsNotAvailable")}
</span>
)}
</div>
);
};
}
@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export type HistoryTab = "live" | "1h" | "6h" | "24h" | "7d";
@@ -1,11 +1,16 @@
import {
RadialGauge,
Sparkline,
MiniStat,
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect } from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function computeChartData(rows: MetricsHistoryRow[]) {
@@ -24,6 +29,7 @@ export function CpuCard({
history: number[];
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
@@ -1,12 +1,17 @@
import {
RadialGauge,
Sparkline,
MiniStat,
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect, useMemo } from "react";
import { HardDrive } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { FilesystemPicker } from "./FilesystemPicker";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function DiskCard({
@@ -18,6 +23,7 @@ export function DiskCard({
history: number[];
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
@@ -1,13 +1,13 @@
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DiskFilesystem } from "@/main-axios";
import { cn } from "@/lib/utils";
import {
cn,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/dropdown-menu";
} from "@termix/plugin-sdk/ui";
import { type DiskFilesystem } from "../../shared/metrics.js";
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export function FilesystemPicker({
filesystems,
@@ -1,7 +1,7 @@
import { MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { ShieldCheck } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
export function FirewallCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -1,10 +1,15 @@
import {
MiniStat,
RadialGauge,
Sparkline,
StatRow,
MetricCard,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { type GpuDevice, type GpuProcess } from "../../shared/stats-widgets.js";
import { useId } from "react";
import { Gpu } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import type { GpuDevice, GpuProcess } from "@/types/stats-widgets";
import { MiniStat, RadialGauge, Sparkline, StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
import type { GpuHistories, GpuHistory } from "./gpu-history";
const NA = "N/A";
@@ -1,7 +1,7 @@
import { MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
export function LoginStatsCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -1,11 +1,16 @@
import {
RadialGauge,
Sparkline,
MiniStat,
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect } from "react";
import { MemoryStick } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function MemoryCard({
@@ -17,6 +22,7 @@ export function MemoryCard({
history: number[];
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
@@ -1,10 +1,13 @@
import {
MetricCard,
LineChart,
type LineChartSeries,
} from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { useState, useEffect } from "react";
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
import { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function ifaceIcon(name: string) {
@@ -65,6 +68,7 @@ export function NetworkCard({
metrics: ServerMetrics | null;
hostId: number | null;
}) {
const { getMetricsHistory } = useHostMetricsApi();
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
@@ -1,9 +1,11 @@
import { MetricCard, Select2 } from "@termix/plugin-sdk/ui";
import {
type ServerMetrics,
type ListeningPort,
} from "../../shared/metrics.js";
import { useMemo, useState } from "react";
import { Unplug, Search } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics, ListeningPort } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
import { Select2 } from "@/components/select2";
function formatAddress(addr: string) {
return addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
@@ -1,8 +1,7 @@
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Cpu } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
export function ProcessesCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -1,8 +1,7 @@
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Server } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
export function SystemCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -1,20 +1,15 @@
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Thermometer } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend";
import { useEffect, useState } from "react";
import {
formatTemperature,
selectTemperatureSensor,
temperaturePreferenceKey,
temperatureUnitOf,
} from "../temperature-preference";
function formatTemperature(value: number | null | undefined): string {
return typeof value === "number" && Number.isFinite(value)
? `${value.toFixed(1)}°C`
: "N/A";
}
export function TemperatureCard({
metrics,
hostId,
@@ -23,6 +18,7 @@ export function TemperatureCard({
hostId: number | null;
}) {
const { t } = useTranslation();
const unit = temperatureUnitOf(useSettings("user").values.temperatureUnit);
const temperature = metrics?.temperature;
const sensors = temperature?.sensors ?? [];
const [preferredLabel, setPreferredLabel] = useState("");
@@ -59,7 +55,7 @@ export function TemperatureCard({
<div className="flex flex-col gap-3">
<div>
<div className="text-3xl font-semibold tabular-nums">
{formatTemperature(displayedTemperature)}
{formatTemperature(displayedTemperature, unit)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{displayedLabel}
@@ -92,7 +88,7 @@ export function TemperatureCard({
<StatRow
key={`${sensor.label}-${sensor.celsius}`}
label={sensor.label}
value={formatTemperature(sensor.celsius)}
value={formatTemperature(sensor.celsius, unit)}
mono
/>
))}
@@ -1,7 +1,7 @@
import { MetricCard } from "@termix/plugin-sdk/ui";
import { type ServerMetrics } from "../../shared/metrics.js";
import { Clock } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
export function UptimeCard({ metrics }: { metrics: ServerMetrics | null }) {
const { t } = useTranslation();
@@ -1,5 +1,3 @@
import type { GpuDevice } from "@/types/stats-widgets";
export interface GpuHistory {
utilization: number[];
memory: number[];
@@ -29,3 +27,4 @@ export function appendGpuHistories(
}
return next;
}
import { type GpuDevice } from "../../shared/stats-widgets.js";
@@ -1,10 +1,10 @@
import type { ComponentType, ReactNode } from "react";
import type { ServerMetrics } from "@/main-axios";
import { type ServerMetrics } from "../../shared/metrics.js";
import {
defaultColSpanFor,
defaultHeightFor,
type HostMetricsCardId,
} from "@/types/host-metrics";
} from "../../shared/host-metrics.js";
import type { ComponentType, ReactNode } from "react";
import { CpuCard } from "./CpuCard";
import { MemoryCard } from "./MemoryCard";
import { DiskCard } from "./DiskCard";
@@ -1,9 +1,9 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useEffect, useMemo, useState } from "react";
import { Clock4, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -16,6 +16,7 @@ interface CronEntry {
}
export function CronManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
entries: CronEntry[];
@@ -1,14 +1,13 @@
import { Button, Select2 } from "@termix/plugin-sdk/ui";
import { type FirewallChain } from "../../../shared/metrics.js";
import { useMemo, useState } from "react";
import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { type FirewallChain } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { Select2 } from "@/components/select2";
interface FirewallData {
type: "iptables" | "nftables" | "none";
@@ -17,6 +16,7 @@ interface FirewallData {
}
export function FirewallManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<FirewallData>(
hostId,
@@ -1,3 +1,4 @@
import { Button, Sparkline, Select2 } from "@termix/plugin-sdk/ui";
import { useEffect, useMemo, useState } from "react";
import {
HeartPulse,
@@ -10,12 +11,9 @@ import {
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Sparkline } from "@/components/charts";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { Select2 } from "@/components/select2";
interface HealthCheck {
id: string;
@@ -54,6 +52,7 @@ function newCheck(): HealthCheck {
}
export function HealthCheckCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<HealthData>(
hostId,
@@ -1,11 +1,10 @@
import { useAdaptivePolling, Select2 } from "@termix/plugin-sdk/ui";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ScrollText } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { managerGet, managerGetSub } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { Select2 } from "@/components/select2";
interface LogFiles {
common: string[];
@@ -15,6 +14,7 @@ interface LogFiles {
type Mode = "file" | "unit";
export function LogViewerCard({ hostId }: { hostId: number | null }) {
const { managerGet, managerGetSub } = useHostMetricsApi();
const { t } = useTranslation();
const [mode, setMode] = useState<Mode>("file");
const [path, setPath] = useState<string>("/var/log/syslog");
@@ -1,8 +1,7 @@
import { Button, MetricCard } from "@termix/plugin-sdk/ui";
import type { ReactNode } from "react";
import { RefreshCw, AlertTriangle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { MetricCard } from "@/components/metric-card";
import type { ManagerError } from "./useManagerData";
export function ManagerCardShell({
@@ -1,9 +1,9 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { Package, ArrowUpCircle } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -15,6 +15,7 @@ interface UpgradablePackage {
}
export function PackageManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
pkg: string | null;
@@ -1,12 +1,12 @@
import { Select2 } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { ListTree, X, Skull } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
import { Select2 } from "@/components/select2";
interface ProcessRow {
pid: number;
@@ -22,6 +22,7 @@ type SortKey = "cpu" | "mem" | "pid";
type Signal = "TERM" | "KILL";
export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
processes: ProcessRow[];
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
import { Cog, Play, Square, RotateCw, Power } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -15,6 +15,7 @@ interface SystemdService {
}
export function ServiceManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<{
services: SystemdService[];
@@ -1,7 +1,7 @@
import { BarSeries, StatRow } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { MemoryStick, Timer, HardDrive } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { BarSeries, StatRow } from "@/components/charts";
import { useManagerData } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,15 +1,14 @@
import { Button, Select2 } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import {
useManagerData,
useManagerAction,
extractError,
} from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { Select2 } from "@/components/select2";
interface CertInfo {
client: string;
@@ -53,6 +52,7 @@ function ExpiryBadge({ expiry }: { expiry: string | null }) {
}
export function SslManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<SslData>(
hostId,
@@ -1,9 +1,9 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useMemo, useState } from "react";
import { Users, Plus, Trash2, ShieldCheck, Shield } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -19,6 +19,7 @@ interface UsersData {
}
export function UserManagerCard({ hostId }: { hostId: number | null }) {
const { managerPost } = useHostMetricsApi();
const { t } = useTranslation();
const { data, loading, error, refresh } = useManagerData<UsersData>(
hostId,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { managerGet, managerPost } from "../../host-metrics-api";
import { useHostMetricsApi } from "../../host-metrics-api";
interface ManagerError {
message: string;
@@ -27,6 +27,7 @@ export function useManagerData<T>(
resource: string,
params?: Record<string, string | number>,
) {
const { managerGet } = useHostMetricsApi();
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ManagerError | null>(null);
@@ -66,6 +67,7 @@ interface ActionResult {
* to refresh the card). Returns the result so callers can branch further.
*/
export function useManagerAction(hostId: number | null) {
const { managerPost } = useHostMetricsApi();
const [busy, setBusy] = useState(false);
const run = useCallback(
@@ -1,9 +1,6 @@
import { type HostMetricsLayout } from "../../shared/host-metrics.js";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getHostMetricsLayout,
saveHostMetricsLayout,
} from "../host-metrics-api";
import type { HostMetricsLayout } from "@/types/host-metrics";
import { useHostMetricsApi } from "../host-metrics-api";
const SAVE_DEBOUNCE_MS = 800;
@@ -38,6 +35,7 @@ function writeCache(hostId: number, layout: HostMetricsLayout) {
* debounced. Returns null layout until the first load resolves.
*/
export function useHostMetricsPreferences(hostId: number | null) {
const { getHostMetricsLayout, saveHostMetricsLayout } = useHostMetricsApi();
const [layout, setLayout] = useState<HostMetricsLayout | null>(() =>
hostId != null ? readCache(hostId) : null,
);
@@ -1,14 +1,14 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
import { useMemo } from "react";
import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type { HostMetricsLayout } from "../shared/host-metrics.js";
import type { ServerMetrics } from "../shared/metrics.js";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- a synced
// host has a different numeric id on each side (only its syncId matches
// across them). These calls always target the embedded local backend; see
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
// call that IS safely merged across local + remote (a process-local,
// in-memory aggregate keyed by whichever host ids that process happens to
// know about, not a per-host lookup).
// Every call is keyed by a host's numeric database id, and the receiving
// backend must own that host in its own database: a synced host has a
// different numeric id on each side. They always target this backend.
export interface MetricsHistoryRow {
ts: string;
@@ -25,63 +25,6 @@ export interface MetricsHistoryResponse {
toTs: string;
}
export async function getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
const res = await statsApi.get(`/metrics/history/${hostId}`, {
params: opts,
});
return res.data as MetricsHistoryResponse;
}
export async function getMetricsHistoryRetention(): Promise<number> {
const res = await statsApi.get("/global-settings/history");
return (res.data as { metricsHistoryRetentionDays: number })
.metricsHistoryRetentionDays;
}
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
await statsApi.post("/global-settings/history", {
metricsHistoryRetentionDays: days,
});
}
/**
* Host Metrics layout persistence (server-synced per user/host) + the manager
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
* stats app (port 30005).
*/
export async function getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
validateStatus: (status) => status === 200 || status === 404,
});
if (res.status === 404) return null;
return res.data?.layout ?? null;
} catch (error) {
handleApiError(error, "fetch host metrics layout");
throw error;
}
}
export async function saveHostMetricsLayout(
hostId: number,
layout: HostMetricsLayout,
): Promise<void> {
try {
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
} catch (error) {
handleApiError(error, "save host metrics layout");
throw error;
}
}
// ─── Managers ───────────────────────────────────────────────────────────────
export interface PlatformInfo {
hasSystemd: boolean;
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
@@ -91,56 +34,193 @@ export interface PlatformInfo {
osPrettyName: string | null;
}
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
return res.data;
export interface ConnectionLogEntry {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
}
/** GET a manager resource (read). */
export async function managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}`,
{ params },
);
return res.data as T;
export interface StartMetricsResult {
success: boolean;
requires_totp?: boolean;
sessionId?: string;
prompt?: string;
viewerSessionId?: string;
connectionLogs?: ConnectionLogEntry[];
}
/**
* GET a manager sub-resource where the host id sits in the middle of the path,
* e.g. /managers/logs/{id}/files.
*/
export async function managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
);
return res.data as T;
function statusOf(error: unknown): number | undefined {
return (error as { response?: { status?: number } })?.response?.status;
}
/**
* POST a manager action. `resource` is the manager name; `action` is the
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
* sits between them: /managers/{resource}/{id}[/{action}].
*/
export async function managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
const res = await statsApi.post(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
);
return res.data as T;
function bodyOf(error: unknown): Record<string, unknown> | undefined {
return (error as { response?: { data?: Record<string, unknown> } })?.response
?.data;
}
/** The plugin's routes, relative to /plugin-api/host-metrics/. */
export function createHostMetricsApi(api: PluginApiClient) {
return {
async getMetrics(hostId: number): Promise<ServerMetrics | null> {
try {
return (await api.get<ServerMetrics>(`/metrics/${hostId}`)).data;
} catch (error) {
// No sample yet, or metrics are off for the host.
if (statusOf(error) === 404) return null;
throw error;
}
},
/** Throws an Error carrying the server's connectionLogs on failure. */
async startMetrics(hostId: number): Promise<StartMetricsResult> {
try {
return (await api.post<StartMetricsResult>(`/metrics/start/${hostId}`))
.data;
} catch (error) {
const body = bodyOf(error);
if (body?.connectionLogs) {
const wrapped = new Error(
String(body.error || body.message || "Failed to start metrics"),
);
Object.assign(wrapped, { connectionLogs: body.connectionLogs });
throw wrapped;
}
throw error;
}
},
async stopMetrics(hostId: number, viewerSessionId?: string) {
await api.post(`/metrics/stop/${hostId}`, { viewerSessionId });
},
async submitTotp(
sessionId: string,
totpCode: string,
): Promise<{ success: boolean; viewerSessionId?: string }> {
return (
await api.post<{ success: boolean; viewerSessionId?: string }>(
"/metrics/connect-totp",
{ sessionId, totpCode },
)
).data;
},
/** False when the server no longer knows the viewer. */
async heartbeat(viewerSessionId: string): Promise<boolean> {
try {
await api.post("/metrics/heartbeat", { viewerSessionId });
return true;
} catch (error) {
if (statusOf(error) === 404) return false;
throw error;
}
},
async registerViewer(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
}> {
return (await api.post("/metrics/register-viewer", { hostId })).data as {
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
};
},
async unregisterViewer(hostId: number, viewerSessionId: string) {
await api.post("/metrics/unregister-viewer", {
hostId,
viewerSessionId,
});
},
async getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
return (
await api.get<MetricsHistoryResponse>(`/metrics/history/${hostId}`, {
params: opts,
})
).data;
},
async getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await api.get<{ layout?: HostMetricsLayout }>(
`/host-metrics/preferences/${hostId}`,
);
return res.data?.layout ?? null;
} catch (error) {
if (statusOf(error) === 404) return null;
throw error;
}
},
async saveHostMetricsLayout(hostId: number, layout: HostMetricsLayout) {
await api.post(`/host-metrics/preferences/${hostId}`, layout);
},
async getHostPlatform(hostId: number): Promise<PlatformInfo> {
return (await api.get<PlatformInfo>(`/host-metrics/platform/${hostId}`))
.data;
},
/** GET a manager resource (read). */
async managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
return (
await api.get<T>(`/host-metrics/managers/${resource}/${hostId}`, {
params,
})
).data;
},
/** GET where the host id sits mid-path: /managers/logs/{id}/files. */
async managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
return (
await api.get<T>(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
)
).data;
},
/** POST a manager action: /managers/{resource}/{id}[/{action}]. */
async managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
return (
await api.post<T>(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
)
).data;
},
};
}
export type HostMetricsApi = ReturnType<typeof createHostMetricsApi>;
export function useHostMetricsApi(): HostMetricsApi {
const api = usePluginApi();
return useMemo(() => createHostMetricsApi(api), [api]);
}
+53 -26
View File
@@ -3,24 +3,37 @@ import { Activity, Server } from "lucide-react";
import type {
HomepageWidgetContribution,
HostEditorSectionProps,
PluginHostRecord,
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "@/types";
import { HostMetricsTab } from "./HostMetricsTab";
import HostMetricsApp from "./HostMetricsApp";
import { HostStatsTab } from "./HostEditorStatsTab";
import { metricsChartWidget } from "./MetricsChartWidget";
import { MetricsRetentionSetting } from "./MetricsRetentionSetting";
import { TerminalMetricsStatus } from "./TerminalMetricsStatus";
import { createHostMetricsApi } from "./host-metrics-api";
import { createMetricsSummaryStore } from "./summary-store";
import {
createDashboardHostMetrics,
createHomepageHostMetrics,
} from "./SummaryViews";
type SectionSetField = Parameters<typeof HostStatsTab>[0]["setField"];
type HostMetricsTabConfig = Parameters<typeof HostMetricsTab>[0]["hostConfig"];
/** This plugin's metricsEnabled host setting; on unless turned off. */
function metricsEnabledFor(host: unknown): boolean {
const bag = (
host as { pluginSettings?: Record<string, Record<string, unknown>> }
)?.pluginSettings;
return bag?.["host-metrics"]?.metricsEnabled !== false;
}
function MetricsTab({ sshHost, label, isVisible }: TabProps) {
return (
<HostMetricsTab
hostConfig={sshHost as unknown as SSHHost}
hostConfig={sshHost as unknown as HostMetricsTabConfig}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
@@ -33,21 +46,25 @@ function MetricsStandalone({ hostId }: StandaloneViewProps) {
return <HostMetricsApp hostId={hostId} />;
}
function MetricsHostSection({
form,
setField,
snippets,
}: HostEditorSectionProps) {
return (
<HostStatsTab
form={form}
setField={setField as SectionSetField}
snippets={(snippets ?? []) as { id: number; name: string }[]}
/>
);
function MetricsHostSection(props: HostEditorSectionProps) {
return <HostStatsTab {...props} />;
}
export function activate(app: TermixApp): void {
export async function activate(app: TermixApp): Promise<void> {
const allowed = await app.hasPermission("use");
const metricsApi = createHostMetricsApi(app.api);
const summaries = createMetricsSummaryStore(metricsApi);
app.onDispose(() => summaries.dispose());
// The latest disk sample, for the file manager's usage bar.
app.registerAction(
"host-metrics.disk",
(async (hostId: number) =>
(await metricsApi.getMetrics(hostId).catch(() => null))?.disk ??
null) as never,
{ permission: "use" },
);
app.registerTab("host-metrics", MetricsTab, {
icon: Server,
titleKey: "nav.hostMetrics",
@@ -70,10 +87,8 @@ export function activate(app: TermixApp): void {
tabType: "host-metrics",
copyUrlView: "host-metrics",
overview: true,
when: (host) =>
!!host.enableSsh &&
(host.statsConfig as { metricsEnabled?: boolean } | undefined)
?.metricsEnabled !== false,
when: (host: PluginHostRecord) =>
allowed && !!host.enableSsh && metricsEnabledFor(host),
});
app.registerHostEditorSection({
@@ -89,17 +104,29 @@ export function activate(app: TermixApp): void {
metricsChartWidget as unknown as HomepageWidgetContribution,
);
app.registerSettingsComponent("retention", MetricsRetentionSetting);
// Live CPU, memory and disk bars in the terminal toolbar's expanded view.
app.registerSlotContribution("terminal.toolbarStatus", {
actionId: "host-metrics.terminalStatus",
titleKey: "nav.hostMetrics",
kind: "component",
component: TerminalMetricsStatus,
when: (context) =>
(context.host as { statsConfig?: { metricsEnabled?: boolean } })
?.statsConfig?.metricsEnabled !== false,
when: (context) => allowed && metricsEnabledFor(context.host),
});
// The usage bars in the dashboard's host status card.
app.registerSlotContribution("dashboard.hostMetrics", {
actionId: "host-metrics.dashboardHost",
titleKey: "nav.hostMetrics",
kind: "component",
component: createDashboardHostMetrics(summaries),
});
// The numbers in the homepage's host status widget.
app.registerSlotContribution("homepage.hostMetrics", {
actionId: "host-metrics.homepageHost",
titleKey: "nav.hostMetrics",
kind: "component",
component: createHomepageHostMetrics(summaries),
});
app.registerSlotContribution("onboarding.features", {
@@ -1,5 +1,3 @@
import type { ServerMetrics } from "@/main-axios";
/**
* Coarse fingerprint of a metrics poll. Adaptive polling backs off while it
* stays the same, so it must move whenever something a card shows changes.
@@ -22,3 +20,4 @@ export function metricsChangeKey(data: ServerMetrics): string {
gpuProcesses: data.gpu?.processes?.length ?? 0,
});
}
import { type ServerMetrics } from "../shared/metrics.js";
@@ -0,0 +1,115 @@
import { useCallback, useSyncExternalStore } from "react";
import type { ServerMetrics } from "../shared/metrics.js";
import type { HostMetricsApi } from "./host-metrics-api";
const REFRESH_MS = 30_000;
interface Entry {
refs: number;
viewerSessionId?: string;
data: ServerMetrics | null;
listeners: Set<() => void>;
}
/**
* Latest metrics for hosts shown outside the tab (dashboard rows, homepage
* widgets). Each watched host holds one viewer, so polling runs while
* something shows it and stops with the last one.
*/
export function createMetricsSummaryStore(api: HostMetricsApi) {
const entries = new Map<number, Entry>();
let timer: ReturnType<typeof setInterval> | null = null;
const notify = (entry: Entry) => {
for (const listener of entry.listeners) listener();
};
async function refresh(hostId: number): Promise<void> {
const entry = entries.get(hostId);
if (!entry) return;
try {
if (
entry.viewerSessionId &&
!(await api.heartbeat(entry.viewerSessionId))
) {
entry.viewerSessionId = undefined;
}
if (!entry.viewerSessionId) {
const registered = await api.registerViewer(hostId);
if (registered.viewerSessionId) {
entry.viewerSessionId = registered.viewerSessionId;
}
}
entry.data = await api.getMetrics(hostId);
} catch {
// Keep the last value; the next tick tries again.
}
if (entries.get(hostId) === entry) notify(entry);
}
function tick() {
if (typeof document !== "undefined" && document.hidden) return;
for (const hostId of entries.keys()) void refresh(hostId);
}
function release(hostId: number, entry: Entry) {
entries.delete(hostId);
if (entry.viewerSessionId) {
void api.unregisterViewer(hostId, entry.viewerSessionId).catch(() => {});
}
if (entries.size === 0 && timer) {
clearInterval(timer);
timer = null;
}
}
return {
watch(hostId: number, listener: () => void): () => void {
let entry = entries.get(hostId);
if (!entry) {
entry = { refs: 0, data: null, listeners: new Set() };
entries.set(hostId, entry);
void refresh(hostId);
}
entry.refs++;
entry.listeners.add(listener);
if (!timer) timer = setInterval(tick, REFRESH_MS);
const held = entry;
return () => {
held.listeners.delete(listener);
held.refs--;
if (held.refs <= 0) release(hostId, held);
};
},
get(hostId: number): ServerMetrics | null {
return entries.get(hostId)?.data ?? null;
},
dispose() {
for (const [hostId, entry] of [...entries.entries()]) {
release(hostId, entry);
}
},
};
}
export type MetricsSummaryStore = ReturnType<typeof createMetricsSummaryStore>;
/** The host's latest sample, polled while the calling component is shown. */
export function useMetricsSummary(
store: MetricsSummaryStore,
hostId: number | null | undefined,
): ServerMetrics | null {
// Stable per host, or every render would drop and re-add the viewer.
const subscribe = useCallback(
(onChange: () => void) =>
hostId ? store.watch(hostId, onChange) : () => {},
[store, hostId],
);
return useSyncExternalStore(
subscribe,
() => (hostId ? store.get(hostId) : null),
() => null,
);
}
@@ -1,4 +1,6 @@
import type { TemperatureSensor } from "@/types/stats-widgets";
import type { TemperatureSensor } from "../shared/stats-widgets.js";
export type TemperatureUnit = "celsius" | "fahrenheit";
export function selectTemperatureSensor(
sensors: TemperatureSensor[],
@@ -7,6 +9,22 @@ export function selectTemperatureSensor(
return sensors.find((sensor) => sensor.label === preferredLabel) ?? null;
}
/** The sensor a person picked is remembered per browser. */
export function temperaturePreferenceKey(hostId: number) {
return `termix-host-metrics:${hostId}:temperature-sensor`;
}
/** The temperatureUnit user setting, celsius for anything else. */
export function temperatureUnitOf(value: unknown): TemperatureUnit {
return value === "fahrenheit" ? "fahrenheit" : "celsius";
}
export function formatTemperature(
celsius: number | null | undefined,
unit: TemperatureUnit = "celsius",
): string {
if (typeof celsius !== "number" || !Number.isFinite(celsius)) return "N/A";
return unit === "fahrenheit"
? `${((celsius * 9) / 5 + 32).toFixed(1)}°F`
: `${celsius.toFixed(1)}°C`;
}
@@ -0,0 +1,31 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30;
export type MetricsChartMetric =
"cpu" | "memory" | "disk" | "net_rx" | "net_tx";
export type MetricsChartRange = "15m" | "1h" | "6h" | "24h";
export interface MetricsChartConfig {
hostId: number;
metric: MetricsChartMetric;
range: MetricsChartRange;
showCurrentValue: boolean;
}
/** What the homepage hands a widget; only the fields this plugin reads. */
export interface WidgetComponentProps<C> {
widget: { title?: string | null };
config: C;
}
export interface WidgetEditFormProps<C> {
config: C;
onChange: (config: C) => void;
}
export type WidgetDefinition<C> = Omit<
HomepageWidgetContribution<C>,
"defaultConfig"
> & { defaultConfig: C };
@@ -0,0 +1,145 @@
import type { WidgetType } from "./stats-widgets.js";
/**
* Host Metrics layout model. Shared by the frontend tab and the backend
* preferences endpoint. Metric card ids reuse the existing `WidgetType` values
* verbatim (so `enabledWidgets` stays valid for the mobile app); manager card
* ids are new and never enter the `enabledWidgets` host setting.
*/
export type HostMetricCardId = WidgetType;
export type HostMetricManagerId =
| "service_manager"
| "process_inspector"
| "log_viewer"
| "cron_manager"
| "package_manager"
| "ssl_manager"
| "firewall_manager"
| "user_manager"
| "health_check"
| "disk_breakdown"
| "systemd_timers"
| "top_memory"
| "wireguard_manager"
| "tailscale_manager";
export type HostMetricsCardId = HostMetricCardId | HostMetricManagerId;
export type HostMetricsColSpan = 1 | 2 | 3;
export interface HostMetricsSlot {
id: HostMetricsCardId;
order: number;
colSpan: HostMetricsColSpan;
height: number | null;
}
export interface HostMetricsLayout {
slots: HostMetricsSlot[];
columns: number;
}
/** Canonical order of metric cards (also the canonical `enabledWidgets` order). */
export const METRIC_CARD_IDS: HostMetricCardId[] = [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
"gpu",
];
export const MANAGER_CARD_IDS: HostMetricManagerId[] = [
"service_manager",
"process_inspector",
"log_viewer",
"cron_manager",
"package_manager",
"ssl_manager",
"firewall_manager",
"user_manager",
"health_check",
"disk_breakdown",
"systemd_timers",
"top_memory",
"wireguard_manager",
"tailscale_manager",
];
export function isMetricCardId(id: string): id is HostMetricCardId {
return (METRIC_CARD_IDS as string[]).includes(id);
}
export function isManagerCardId(id: string): id is HostMetricManagerId {
return (MANAGER_CARD_IDS as string[]).includes(id);
}
/**
* Derive the flat `enabledWidgets` array (canonical order, deduped, metric ids
* only) from a set of layout slots. This is the single source of truth keeping
* the `enabledWidgets` host setting valid for the mobile app.
*/
export function deriveEnabledWidgets(
slots: Array<{ id: string }>,
): WidgetType[] {
const present = new Set(slots.map((s) => s.id));
return METRIC_CARD_IDS.filter((id) => present.has(id));
}
const DEFAULT_COLSPAN: Partial<Record<HostMetricsCardId, HostMetricsColSpan>> =
{
cpu: 1,
memory: 1,
disk: 1,
uptime: 1,
system: 1,
network: 1,
temperature: 1,
processes: 2,
ports: 2,
firewall: 2,
login_stats: 2,
gpu: 2,
};
// All cards default to content/auto height (null); the masonry packs them
// tetris-style and scrollable cards cap their own body height. Users can still
// pin an explicit height by dragging a card's resize handle.
const DEFAULT_HEIGHT: Partial<Record<HostMetricsCardId, number | null>> = {};
/**
* Build a default layout from a list of enabled metric-card ids (e.g. the host's
* current the `enabledWidgets` host setting), preserving canonical order. Manager
* cards are not added by default.
*/
export function defaultLayoutFromWidgets(
enabledWidgets: string[],
columns = 3,
): HostMetricsLayout {
const enabled = new Set(enabledWidgets);
const ids = METRIC_CARD_IDS.filter((id) => enabled.has(id));
const slots: HostMetricsSlot[] = ids.map((id, i) => ({
id,
order: i,
colSpan: DEFAULT_COLSPAN[id] ?? 1,
height: DEFAULT_HEIGHT[id] ?? null,
}));
return { slots, columns };
}
export function defaultColSpanFor(id: HostMetricsCardId): HostMetricsColSpan {
return DEFAULT_COLSPAN[id] ?? (isManagerCardId(id) ? 2 : 1);
}
export function defaultHeightFor(id: HostMetricsCardId): number | null {
if (id in DEFAULT_HEIGHT) return DEFAULT_HEIGHT[id] ?? null;
return null;
}
+136
View File
@@ -0,0 +1,136 @@
import type { GpuMetrics } from "./stats-widgets.js";
/** What GET /metrics/:id answers. */
export interface CpuMetrics {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}
export interface MemoryMetrics {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
interface DiskMetrics {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman?: string | null;
mount?: string | null;
filesystems?: DiskFilesystem[];
}
export interface NetworkInterface {
name: string;
ip: string;
state: string;
rx?: string | null;
tx?: string | null;
rxBytes?: string | null;
txBytes?: string | null;
rxRateBps?: number | null;
txRateBps?: number | null;
}
export interface ProcessInfo {
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface ListeningPort {
protocol: "tcp" | "udp";
localAddress: string;
localPort: number;
state?: string;
pid?: number;
process?: string;
}
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export type ServerMetrics = {
cpu: CpuMetrics;
memory: MemoryMetrics;
disk: DiskMetrics;
network?: { interfaces?: NetworkInterface[] };
uptime?: { seconds?: number | null; formatted?: string | null };
system?: {
hostname?: string | null;
os?: string | null;
kernel?: string | null;
arch?: string | null;
};
processes?: {
total?: number | null;
running?: number | null;
top?: ProcessInfo[];
};
login_stats?: {
recentLogins?: LoginRecord[];
failedLogins?: LoginRecord[];
totalLogins?: number;
uniqueIPs?: number;
};
ports?: {
source?: "ss" | "netstat" | "none";
ports?: ListeningPort[];
};
firewall?: {
type?: "iptables" | "nftables" | "none";
status?: "active" | "inactive" | "unknown";
chains?: FirewallChain[];
};
temperature?: {
source?: "sysfs" | "sensors" | "none";
highestCelsius?: number | null;
sensors?: Array<{
label: string;
celsius: number;
}>;
};
gpu?: GpuMetrics;
lastChecked: string;
};
@@ -0,0 +1,161 @@
export type WidgetType =
| "cpu"
| "memory"
| "disk"
| "network"
| "uptime"
| "processes"
| "system"
| "login_stats"
| "ports"
| "firewall"
| "temperature"
| "gpu";
export interface ListeningPort {
protocol: "tcp" | "udp";
localAddress: string;
localPort: number;
state?: string;
pid?: number;
process?: string;
}
export interface PortsMetrics {
source: "ss" | "netstat" | "none";
ports: ListeningPort[];
}
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export interface FirewallMetrics {
type: "iptables" | "nftables" | "none";
status: "active" | "inactive" | "unknown";
chains: FirewallChain[];
}
export interface TemperatureSensor {
label: string;
celsius: number;
}
export interface TemperatureMetrics {
source: "sysfs" | "sensors" | "none";
highestCelsius: number | null;
sensors: TemperatureSensor[];
}
export interface GpuDevice {
index: number;
uuid: string;
name: string;
driverVersion: string | null;
utilizationPercent: number | null;
memoryUsedMiB: number | null;
memoryTotalMiB: number | null;
memoryPercent: number | null;
temperatureCelsius: number | null;
powerDrawWatts: number | null;
powerLimitWatts: number | null;
fanPercent: number | null;
}
export interface GpuProcess {
gpuIndex: number | null;
pid: number;
name: string;
memoryUsedMiB: number | null;
}
export interface GpuMetrics {
source: "nvidia-smi" | "none";
gpus: GpuDevice[];
processes: GpuProcess[];
}
/** This plugin's host settings, as core stores them in plugin_settings. */
export interface HostMetricsSettings {
metricsEnabled: boolean;
/** Seconds between samples; null follows the admin setting. */
metricsInterval: number | null;
enabledWidgets: WidgetType[];
/** Filesystem mount points to leave out of the disk widget. */
excludedMounts: string[];
/** Extra paths to monitor, including paths inside bind-mounted containers. */
monitoredMounts: Array<{ path: string; label?: string }>;
}
export const DEFAULT_ENABLED_WIDGETS: WidgetType[] = [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
];
export const DEFAULT_HOST_METRICS_SETTINGS: HostMetricsSettings = {
metricsEnabled: true,
metricsInterval: null,
enabledWidgets: DEFAULT_ENABLED_WIDGETS,
excludedMounts: [],
monitoredMounts: [],
};
/** Host settings with defaults filled in and bad values dropped. */
export function readHostMetricsSettings(
values: Record<string, unknown> | null | undefined,
): HostMetricsSettings {
const bag = values ?? {};
const interval = Number(bag.metricsInterval);
return {
metricsEnabled: bag.metricsEnabled !== false,
metricsInterval:
bag.metricsInterval != null &&
Number.isInteger(interval) &&
interval >= 5 &&
interval <= 3600
? interval
: null,
enabledWidgets: Array.isArray(bag.enabledWidgets)
? (bag.enabledWidgets.filter(
(widget) => typeof widget === "string",
) as WidgetType[])
: DEFAULT_ENABLED_WIDGETS,
excludedMounts: Array.isArray(bag.excludedMounts)
? bag.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
)
: [],
monitoredMounts: Array.isArray(bag.monitoredMounts)
? (bag.monitoredMounts as unknown[]).filter(
(mount): mount is { path: string; label?: string } =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
)
: [],
};
}