feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+22
View File
@@ -0,0 +1,22 @@
# Host Metrics
Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).
Bundled with Termix and enabled by default.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx) / deactivate(), plus the routes
src/frontend/ the UI it registers
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
## Commands
npm run build bundle into dist/
npm run test run this plugin's tests
npm run typecheck
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
-66
View File
@@ -1,66 +0,0 @@
/**
* Host Metrics - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ssh-terminal and docker do (see src/backend/plugins/first-party.ts):
* its polling manager holds long-lived ssh2.Client connections across a
* whole fleet of hosts, reused between polling ticks, and its connect flow
* implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault
* keyboard-interactive handshakes the terminal does. None of that can cross
* the structured-clone postMessage boundary a worker plugin talks over, and
* the worker ctx.ssh primitive (connect + single exec-and-collect) has no
* hook for a session that outlives one request.
*
* routes.ts is what used to be src/backend/hosts/metrics/index.ts, moved
* here and rewritten from a module-scope SIGINT/SIGTERM + listenOnServicePort
* call into startHostMetricsService()/shutdown(), called from
* activate()/deactivate() below.
*
* Proxmox-related code (metrics/proxmox/*, proxmox-stats-*) stays in core at
* src/backend/hosts/metrics/ rather than moving here -- it is planned as its
* own future plugin, so this move does not touch it.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled
* .js for the moved host-metrics files (routes.js, ...) sits right next to
* this .mjs, and core modules resolve via ../../../backend/backend/... from
* there (dist/plugins/host-metrics/backend -> dist/backend/backend). Under
* vitest/dev nothing imports this plugin directly (the loader loads
* in-process plugins with a plain dynamic import, no tsx loader involved),
* so the only layout that needs to resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let routesModule = null;
let httpServer = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
httpServer = routesModule.startHostMetricsService();
ctx.log.info(`Host Metrics listening on ${routesModule.PORT ?? 30005}`);
}
export async function deactivate() {
if (routesModule) {
routesModule.shutdown();
routesModule = null;
}
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise((resolve) => server.close(() => resolve()));
}
}
+1
View File
@@ -0,0 +1 @@
{}
+3 -2
View File
@@ -23,6 +23,9 @@
"events:core",
"ui:surface"
],
"optionalDependencies": {
"automations": "^1.0.0"
},
"contributes": {
"tabs": [
{
@@ -33,8 +36,6 @@
}
]
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/host-metrics",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
@@ -11,8 +11,11 @@
* `hosts/automation-events.ts` -- that one is shared across features.
*/
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
import type { MetricsSnapshot } from "../../automations/backend/conditions.js";
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
import type { MetricsSnapshot } from "../../../automations/src/backend/conditions.js";
export interface HostMetricsPayload {
hostId: number;
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
export type StatsCapableHost = {
connectionType?: string;
@@ -1,8 +1,8 @@
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";
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;
+20
View File
@@ -0,0 +1,20 @@
import type { Server } from "node:http";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { PORT, shutdown, startHostMetricsService } from "./routes.js";
let httpServer: Server | null = null;
export async function activate(ctx: PluginContext) {
httpServer = startHostMetricsService();
ctx.log.info(`Host Metrics listening on ${PORT ?? 30005}`);
}
export async function deactivate() {
shutdown();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
@@ -1,7 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
@@ -1,6 +1,6 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPort,
@@ -8,8 +8,8 @@ import {
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
@@ -1,11 +1,11 @@
import type { Express } 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 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 { 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";
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
@@ -1,14 +1,14 @@
import type { Express } from "express";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isAllowedPath,
isValidSystemdUnit,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
@@ -1,15 +1,15 @@
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 } 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 { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import {
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
@@ -1,12 +1,12 @@
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 } 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 {
isValidPid,
isValidSignal,
type Signal,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
@@ -1,11 +1,11 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
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 { 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 { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -1,12 +1,12 @@
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 } 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 {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
@@ -1,16 +1,16 @@
import type { Express } from "express";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execElevated,
shellSingleQuote,
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
@@ -1,9 +1,9 @@
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 } 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 type { ManagerRoutesDeps } from "./types.js";
import { isValidTailscaleAction } from "../../../../src/backend/hosts/metrics-shared/validation.js";
import { isValidTailscaleAction } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
export interface TailscalePeer {
hostname: string;
@@ -1,6 +1,6 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
@@ -1,11 +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 { 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 {
isValidUsername,
isValidGroupName,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
@@ -1,11 +1,11 @@
import type { Express } from "express";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
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 {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
export interface WireGuardPeer {
publicKey: string;
@@ -1,13 +1,13 @@
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 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";
} from "../../../../src/types/host-metrics.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
@@ -1,33 +1,33 @@
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import net from "net";
import { createCorsMiddleware } from "../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../src/backend/utils/compression-config.js";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../../../src/backend/utils/ssh-algorithms.js";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import {
pickResolvedPassword,
pickResolvedUsername,
} from "../../../src/backend/hosts/credential-username.js";
} from "../../../../src/backend/hosts/credential-username.js";
import {
getCurrentSettingValue,
createCurrentHostMetricsHistoryRepository,
createCurrentHostResolutionRepository,
} from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
} from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
import type {
AuthenticatedRequest,
ProxyNode,
} from "../../../src/types/index.js";
} from "../../../../src/types/index.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../src/types/connection-log.js";
} from "../../../../src/types/connection-log.js";
import {
collectCpuMetrics,
clearCpuSampleCache,
@@ -44,7 +44,7 @@ import {
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { detectPlatform } from "../../../src/backend/hosts/metrics-shared/common-utils.js";
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
@@ -53,33 +53,36 @@ import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import {
createSocks5Connection,
type SOCKS5Config,
} from "../../../src/backend/utils/socks5-helper.js";
import { preparePrivateKeyForSSH2 } from "../../../src/backend/utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
} from "../../../../src/backend/utils/socks5-helper.js";
import { preparePrivateKeyForSSH2 } from "../../../../src/backend/utils/ssh-key-utils.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import {
connectionPool,
withConnection,
} from "../../../src/backend/hosts/ssh-connection-pool.js";
} from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { registerHostMetricsSettingsRoutes } from "./settings-routes.js";
import { registerHostMetricsViewerRoutes } from "./viewer-routes.js";
import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js";
import { registerHostMetricsHistoryRoutes } from "./history-routes.js";
import { registerProxmoxStatsRoutes } from "../../../src/backend/hosts/metrics/proxmox-stats-routes.js";
import { registerProxmoxStatsHistoryRoutes } from "../../../src/backend/hosts/metrics/proxmox-stats-history-routes.js";
import { ProxmoxPollingManager } from "../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
import type { HostSessionStatusPayload } from "../../../src/backend/hosts/host-session-status.js";
import { pluginEvents, TOPICS } from "../../../src/backend/plugins/events.js";
import { registerProxmoxStatsRoutes } from "../../../../src/backend/hosts/metrics/proxmox-stats-routes.js";
import { registerProxmoxStatsHistoryRoutes } from "../../../../src/backend/hosts/metrics/proxmox-stats-history-routes.js";
import { ProxmoxPollingManager } from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
import type { HostSessionStatusPayload } from "../../../../src/backend/hosts/host-session-status.js";
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
import {
notifyAutomationMetrics,
notifyAutomationStatus,
} from "./automation-bridge.js";
import { notifyAutomationInternalEvent } from "../../../src/backend/hosts/automation-events.js";
import { notifyAutomationInternalEvent } from "../../../../src/backend/hosts/automation-events.js";
import { registerManagerRoutes } from "./managers/index.js";
import { resolveSshConnectConfigHost } from "../../../src/backend/hosts/ssh-dns.js";
import { resolveSshConnectConfigHost } from "../../../../src/backend/hosts/ssh-dns.js";
import { AccessDeniedError } from "./managers/route-helpers.js";
import type { ManagerHost } from "./managers/types.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import {
isTcpPingEnabled,
parseStatusHostIds,
@@ -91,8 +94,8 @@ import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../src/backend/hosts/metrics-shared/host-status.js";
import { createConnectionLog } from "../../../src/backend/hosts/connection-log.js";
} from "../../../../src/backend/hosts/metrics-shared/host-status.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import {
cleanupMetricsSession,
getSessionKey,
@@ -112,8 +115,8 @@ import {
pollingBackoff,
requestQueue,
statusPollLimiter,
} from "../../../src/backend/hosts/metrics-shared/state.js";
import { listenOnServicePort } from "../../../src/backend/utils/service-listen.js";
} from "../../../../src/backend/hosts/metrics-shared/state.js";
import { listenOnServicePort } from "../../../../src/backend/utils/service-listen.js";
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
@@ -1097,7 +1100,7 @@ app.post("/internal/login-alert", async (req, res) => {
return res.status(403).json({ error: "Forbidden" });
}
const systemCrypto = (
await import("../../../src/backend/utils/system-crypto.js")
await import("../../../../src/backend/utils/system-crypto.js")
).SystemCrypto;
const expectedToken = await systemCrypto.getInstance().getInternalAuthToken();
const token = req.headers["x-internal-auth"];
@@ -1242,7 +1245,7 @@ async function resolveHostCredentials(
if (isSharedHost) {
const { SharedHostSecretsManager } =
await import("../../../src/backend/utils/shared-host-secrets-manager.js");
await import("../../../../src/backend/utils/shared-host-secrets-manager.js");
const sharedCred =
await SharedHostSecretsManager.getInstance().getSecretForUser(
host.id as number,
@@ -1510,7 +1513,7 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
// Set up OPKSSH cert auth if needed (requires client instance)
if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
throw new Error(
@@ -1518,11 +1521,11 @@ function createSshFactory(host: SSHHostWithCredentials): () => Promise<Client> {
);
}
const { setupOPKSSHCertAuth } =
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../../../src/backend/hosts/vault-ssh-connect.js");
await import("../../../../src/backend/hosts/vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
@@ -2443,7 +2446,7 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
if (usesIssuedCertificate(host.authType) && host.userId) {
const { getOPKSSHToken } =
await import("../../../src/backend/hosts/opkssh-auth.js");
await import("../../../../src/backend/hosts/opkssh-auth.js");
const token = await getOPKSSHToken(host.userId, host.id);
if (!token) {
connectionLogs.push(
@@ -2460,11 +2463,11 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => {
});
}
const { setupOPKSSHCertAuth } =
await import("../../../src/backend/hosts/opkssh-cert-auth.js");
await import("../../../../src/backend/hosts/opkssh-cert-auth.js");
await setupOPKSSHCertAuth(config, client, token, host.username);
} else if (host.authType === "vault") {
const { setupVaultSshSignerAuth } =
await import("../../../src/backend/hosts/vault-ssh-connect.js");
await import("../../../../src/backend/hosts/vault-ssh-connect.js");
await setupVaultSshSignerAuth(config, client, host);
}
@@ -3033,7 +3036,7 @@ app.post("/metrics/connect-totp", async (req, res) => {
// Lets automations keep metrics flowing for hosts they watch, through the same
// viewer refcount the UI uses rather than around it.
import("../../automations/backend/headless-viewer.js")
import("../../../automations/src/backend/headless-viewer.js")
.then(({ setViewerRegistry }) => {
setViewerRegistry({
registerViewer: (hostId, sessionId, userId) =>
@@ -1,5 +1,5 @@
import type { Client, ConnectConfig } from "ssh2";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
export interface MetricsSession {
client: Client;
@@ -1,6 +1,6 @@
import type { Express, RequestHandler } from "express";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
@@ -1,8 +1,8 @@
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
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";
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;
@@ -4,7 +4,7 @@ import {
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
/**
* CPU percent is derived from the delta against the previous poll's
@@ -4,7 +4,7 @@ import {
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../../src/types/stats-widgets.js";
} from "../../../../../src/types/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
@@ -2,7 +2,7 @@ import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface GpuDevice {
index: number;
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface LoginRecord {
user: string;
@@ -5,7 +5,7 @@ import {
toFixedNum,
kibToGiB,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
@@ -3,7 +3,7 @@ import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface NetworkCounters {
rx: string;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
PortsMetrics,
ListeningPort,
} from "../../../../src/types/stats-widgets.js";
} from "../../../../../src/types/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
@@ -1,5 +1,5 @@
import type { Client } from "ssh2";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
@@ -3,7 +3,7 @@ import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
@@ -2,7 +2,7 @@ import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface TemperatureSensor {
label: string;
@@ -3,7 +3,7 @@ import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);
@@ -0,0 +1,136 @@
import type { Client } from "ssh2";
import { describe, it, expect, vi } from "vitest";
import {
supportsMetrics,
isTcpPingEnabled,
parseStatusHostIds,
tcpPingThroughJumpHost,
} from "../../src/backend/helpers.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
describe("supportsMetrics", () => {
it("supports plain ssh hosts", () => {
expect(
supportsMetrics({ connectionType: "ssh", authType: "password" }),
).toBe(true);
});
it("defaults missing connectionType to ssh", () => {
expect(supportsMetrics({ authType: "key" })).toBe(true);
});
it("rejects non-ssh connection types", () => {
expect(supportsMetrics({ connectionType: "rdp" })).toBe(false);
expect(supportsMetrics({ connectionType: "vnc" })).toBe(false);
expect(supportsMetrics({ connectionType: "telnet" })).toBe(false);
});
it("rejects ssh hosts that cannot run shell commands", () => {
expect(supportsMetrics({ connectionType: "ssh", authType: "none" })).toBe(
false,
);
expect(supportsMetrics({ connectionType: "ssh", authType: "opkssh" })).toBe(
false,
);
});
});
describe("isTcpPingEnabled", () => {
it("is enabled when status checks are on and tcp ping is not disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: false }),
).toBe(true);
expect(isTcpPingEnabled({ statusCheckEnabled: true })).toBe(true);
});
it("is disabled when status checks are off", () => {
expect(isTcpPingEnabled({ statusCheckEnabled: false })).toBe(false);
});
it("is disabled when tcp ping is explicitly disabled", () => {
expect(
isTcpPingEnabled({ statusCheckEnabled: true, disableTcpPing: true }),
).toBe(false);
});
});
describe("parseStatusHostIds", () => {
it("distinguishes an unrestricted request from an empty host set", () => {
expect(parseStatusHostIds(undefined)).toBeNull();
expect(parseStatusHostIds("")).toEqual(new Set());
});
it("keeps only valid positive host IDs", () => {
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
});
});
describe("createConnectionLog", () => {
it("builds a log entry without id/timestamp", () => {
const entry = createConnectionLog("info", "connection", "Connecting", {
hostId: 1,
});
expect(entry).toEqual({
type: "info",
stage: "connection",
message: "Connecting",
details: { hostId: 1 },
});
expect("id" in entry).toBe(false);
expect("timestamp" in entry).toBe(false);
});
});
describe("tcpPingThroughJumpHost", () => {
it("reports the final destination online when forwarding succeeds", async () => {
const stream = { destroy: vi.fn() };
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
expect(host).toBe("private.example");
expect(port).toBe(22);
callback(undefined, stream);
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(true);
expect(stream.destroy).toHaveBeenCalledOnce();
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding fails", async () => {
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
callback(new Error("Connection refused"));
}),
} as unknown as Pick<Client, "forwardOut" | "end">;
await expect(
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
});
it("reports the final destination offline when forwarding times out", async () => {
vi.useFakeTimers();
const jumpClient = {
end: vi.fn(),
forwardOut: vi.fn(),
} as unknown as Pick<Client, "forwardOut" | "end">;
const result = tcpPingThroughJumpHost(
jumpClient,
"private.example",
22,
5000,
);
await vi.advanceTimersByTimeAsync(5000);
await expect(result).resolves.toBe(false);
expect(jumpClient.end).toHaveBeenCalledOnce();
vi.useRealTimers();
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
isMetricCardId,
isManagerCardId,
METRIC_CARD_IDS,
} from "../../../../src/types/host-metrics.js";
describe("deriveEnabledWidgets", () => {
it("returns only metric ids, in canonical order, deduped", () => {
const slots = [
{ id: "firewall" },
{ id: "cpu" },
{ id: "cpu" },
{ id: "memory" },
];
expect(deriveEnabledWidgets(slots)).toEqual(["cpu", "memory", "firewall"]);
});
it("excludes manager card ids (mobile contract: never leak managers)", () => {
const slots = [
{ id: "cpu" },
{ id: "service_manager" },
{ id: "log_viewer" },
{ id: "disk" },
];
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(["cpu", "disk"]);
for (const id of out) expect(isMetricCardId(id)).toBe(true);
});
it("output is always a subset of the 10 known WidgetTypes", () => {
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
]);
const out = deriveEnabledWidgets(slots);
expect(out).toEqual(METRIC_CARD_IDS);
expect(out.length).toBe(METRIC_CARD_IDS.length);
});
it("empty slots -> empty widgets", () => {
expect(deriveEnabledWidgets([])).toEqual([]);
});
});
describe("defaultLayoutFromWidgets", () => {
it("builds slots in canonical order with dense ordering", () => {
const layout = defaultLayoutFromWidgets(["disk", "cpu", "memory"]);
expect(layout.slots.map((s) => s.id)).toEqual(["cpu", "memory", "disk"]);
expect(layout.slots.map((s) => s.order)).toEqual([0, 1, 2]);
expect(layout.columns).toBe(3);
});
it("ignores unknown widget ids", () => {
const layout = defaultLayoutFromWidgets(["cpu", "nope", "service_manager"]);
expect(layout.slots.map((s) => s.id)).toEqual(["cpu"]);
});
it("assigns valid colSpans (1..3)", () => {
const layout = defaultLayoutFromWidgets([...METRIC_CARD_IDS]);
for (const s of layout.slots) {
expect([1, 2, 3]).toContain(s.colSpan);
}
});
it("keeps the opt-in gpu card when deriving enabledWidgets", () => {
expect(deriveEnabledWidgets([{ id: "gpu" }, { id: "cpu" }])).toEqual([
"cpu",
"gpu",
]);
});
it("round-trips: deriveEnabledWidgets(defaultLayout) === input order", () => {
const layout = defaultLayoutFromWidgets(["ports", "cpu", "firewall"]);
expect(deriveEnabledWidgets(layout.slots)).toEqual([
"cpu",
"ports",
"firewall",
]);
});
});
describe("card id guards", () => {
it("classifies metric vs manager ids", () => {
expect(isMetricCardId("cpu")).toBe(true);
expect(isMetricCardId("service_manager")).toBe(false);
expect(isManagerCardId("service_manager")).toBe(true);
expect(isManagerCardId("cpu")).toBe(false);
});
});
@@ -0,0 +1,94 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
// Load the production route without starting metrics polling or SSH services.
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
node.expression.expression.getText(ast) === "app.post" &&
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
);
if (!route) throw new Error("Login event route is missing");
const notify = vi.fn();
let handler: (req: unknown, res: unknown) => Promise<void>;
runInNewContext(
ts.transpileModule(route.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
app: {
post: (_path: string, callback: typeof handler) => {
handler = callback;
},
},
require: () => ({
SystemCrypto: {
getInstance: () => ({
getInternalAuthToken: async () => "internal-test-token",
}),
},
}),
notifyAutomationInternalEvent: notify,
},
);
const body = {
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
};
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", async () => {
const res = response();
await handler(
{
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-internal-auth": "internal-test-token" },
body,
},
res,
);
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(res.json).toHaveBeenCalledWith({ ok: true });
});
it.each([
["192.0.2.9", "internal-test-token"],
["127.0.0.1", "incorrect"],
])(
"rejects untrusted login notifications (%s)",
async (remoteAddress, token) => {
const res = response();
await handler(
{
socket: { remoteAddress },
headers: { "x-internal-auth": token },
body,
},
res,
);
expect(res.status).toHaveBeenCalledWith(403);
expect(notify).not.toHaveBeenCalled();
},
);
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for: the /internal/login-alert route was registered
// after the global JWT auth middleware, so every service-to-service login
// alert got rejected with 401 before the route's own IP+token check ever
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
// polling managers, etc.) just to hit this one route is out of scope, so
// this asserts the registration order directly against the source instead.
describe("metrics service /internal/login-alert route order", () => {
it("is registered before the global auth middleware", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/backend/routes.ts"),
"utf8",
);
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
const authMiddlewareIndex = source.indexOf(
"app.use(authManager.createAuthMiddleware())",
);
expect(routeIndex).toBeGreaterThan(-1);
expect(authMiddlewareIndex).toBeGreaterThan(-1);
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
});
});
@@ -0,0 +1,440 @@
import { describe, it, expect } from "vitest";
import {
buildSudoCommand,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { parsePlatformProbe } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import {
isValidSystemdUnit,
isValidPid,
isValidPort,
isValidPackageName,
isValidUsername,
isValidDomain,
isValidDnsProvider,
isValidSignal,
isValidServiceAction,
isAllowedPath,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import {
parseServiceList,
buildServiceActionCommand,
} from "../../../src/backend/managers/services.js";
import {
parseProcessList,
buildKillCommand,
} from "../../../src/backend/managers/processes.js";
import {
parseDfMounts,
parseTopMemory,
} from "../../../src/backend/managers/simple-reads.js";
import {
parseCrontab,
serializeCrontab,
isValidCronSchedule,
buildApplyCrontabCommand,
} from "../../../src/backend/managers/cron.js";
import {
buildPackageActionCommand,
parseUpgradable,
buildListUpgradableCommand,
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
import {
buildIssueCommand,
buildRenewCommand,
buildRevokeCommand,
isValidCertName,
parseCertbotCertificates,
} from "../../../src/backend/managers/ssl.js";
import {
buildIptablesRuleCommand,
buildNftRuleCommand,
} from "../../../src/backend/managers/firewall.js";
import {
parsePasswd,
parseSudoers,
} from "../../../src/backend/managers/users.js";
import {
buildHealthCheckCommand,
parseHealthResult,
} from "../../../src/backend/managers/health.js";
import {
buildTailCommand,
clampLines,
} from "../../../src/backend/managers/logs.js";
describe("exec-elevated", () => {
it("single-quotes and escapes for the shell", () => {
expect(shellSingleQuote("abc")).toBe("'abc'");
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
});
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
);
});
it("does not merge stderr into stdout (no 2>&1)", () => {
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
});
it("escapes a password containing a quote", () => {
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
});
});
describe("platform probe parsing", () => {
it("parses capabilities and prefers dnf over yum", () => {
const out = [
"systemd=1",
"apt=0",
"dnf=1",
"yum=1",
"pacman=0",
"certbot=1",
"acmesh=0",
"docker=1",
"os=Fedora Linux 40",
].join("\n");
const p = parsePlatformProbe(out);
expect(p.hasSystemd).toBe(true);
expect(p.pkg).toBe("dnf");
expect(p.hasCertbot).toBe(true);
expect(p.hasAcmeSh).toBe(false);
expect(p.hasDocker).toBe(true);
expect(p.osPrettyName).toBe("Fedora Linux 40");
});
it("picks apt when present", () => {
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
});
});
describe("validation (injection defense)", () => {
it("systemd units", () => {
expect(isValidSystemdUnit("nginx.service")).toBe(true);
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
expect(isValidSystemdUnit("nginx")).toBe(false);
});
it("pids", () => {
expect(isValidPid(123)).toBe(true);
expect(isValidPid("123")).toBe(true);
expect(isValidPid(0)).toBe(false);
expect(isValidPid("1; reboot")).toBe(false);
expect(isValidPid(-5)).toBe(false);
});
it("ports", () => {
expect(isValidPort(443)).toBe(true);
expect(isValidPort(0)).toBe(false);
expect(isValidPort(70000)).toBe(false);
});
it("package names", () => {
expect(isValidPackageName("nginx")).toBe(true);
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
expect(isValidPackageName("nginx && curl evil")).toBe(false);
expect(isValidPackageName("-rf")).toBe(false);
});
it("usernames and domains", () => {
expect(isValidUsername("deploy")).toBe(true);
expect(isValidUsername("root; rm")).toBe(false);
expect(isValidDomain("example.com")).toBe(true);
expect(isValidDomain("*.example.com")).toBe(true);
expect(isValidDomain("ex ample.com")).toBe(false);
expect(isValidDomain("a;b.com")).toBe(false);
});
it("dns providers, signals, service actions", () => {
expect(isValidDnsProvider("cloudflare")).toBe(true);
expect(isValidDnsProvider("cf; rm")).toBe(false);
expect(isValidSignal("KILL")).toBe(true);
expect(isValidSignal("BOOM")).toBe(false);
expect(isValidServiceAction("restart")).toBe(true);
expect(isValidServiceAction("destroy")).toBe(false);
});
it("path allowlist rejects traversal and out-of-allowlist", () => {
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
false,
);
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
});
});
describe("services", () => {
it("parses list-units --plain", () => {
const out =
"nginx.service loaded active running A high performance web server\n" +
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
"cron.service loaded inactive dead Regular background program";
const rows = parseServiceList(out);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
expect(rows[2].active).toBe("inactive");
});
it("builds action command", () => {
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
"systemctl restart nginx.service",
);
});
});
describe("processes", () => {
it("parses ps output", () => {
const out =
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
const rows = parseProcessList(out);
expect(rows[0]).toMatchObject({
pid: 1234,
user: "root",
cpu: 12.5,
command: "/usr/bin/node",
});
expect(rows[1].pid).toBe(5678);
});
it("builds kill command", () => {
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
});
});
describe("simple reads", () => {
it("parses df -Pk and skips tmpfs", () => {
const out =
"/dev/sda1 100000 40000 60000 40% /\n" +
"tmpfs 8000 0 8000 0% /dev/shm\n" +
"/dev/sdb1 200000 100000 100000 50% /data";
const mounts = parseDfMounts(out);
expect(mounts).toHaveLength(2);
expect(mounts[0].mount).toBe("/");
expect(mounts[1].usePct).toBe(50);
});
it("parses top by memory", () => {
const rows = parseTopMemory("1234 root 5.5 50000 node");
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
});
});
describe("cron", () => {
it("parses enabled and toggled entries", () => {
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
const entries = parseCrontab(out);
expect(entries).toHaveLength(2);
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
expect(entries[1].enabled).toBe(false);
});
it("validates schedules", () => {
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
expect(isValidCronSchedule("@daily")).toBe(true);
expect(isValidCronSchedule("not a schedule")).toBe(false);
});
it("serializes (commenting disabled entries) and round-trips", () => {
const body = serializeCrontab([
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
]);
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
const reparsed = parseCrontab(body);
expect(reparsed[0].enabled).toBe(true);
expect(reparsed[1].enabled).toBe(false);
});
it("builds apply command piping into crontab -", () => {
expect(buildApplyCrontabCommand("x\n")).toBe(
`printf '%s' 'x\n' | crontab -`,
);
});
});
describe("packages", () => {
it("builds per-distro commands", () => {
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
"apt-get -y install nginx",
);
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
"pacman -Syu --noconfirm",
);
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
});
it("lists per distro", () => {
expect(buildListUpgradableCommand("apt")).toContain(
"apt list --upgradable",
);
expect(buildListUpgradableCommand(null)).toBeNull();
});
it("parses apt upgradable", () => {
const out =
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
const pkgs = parseUpgradable("apt", out);
expect(pkgs[0]).toMatchObject({
name: "nginx",
newVersion: "1.18.0-2",
currentVersion: "1.18.0-1",
});
});
it("parses pacman upgradable", () => {
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
});
});
describe("ssl (dual client)", () => {
it("builds certbot issue for http + dns", () => {
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "http-standalone",
}),
).toContain(
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
);
expect(
buildIssueCommand({
client: "certbot",
domains: ["a.com"],
challenge: "dns",
dnsProvider: "cloudflare",
}),
).toContain("--dns-cloudflare");
});
it("builds acme.sh issue", () => {
const cmd = buildIssueCommand({
client: "acme.sh",
domains: ["a.com", "b.com"],
challenge: "dns",
dnsProvider: "cf",
});
expect(cmd).toContain("--issue --dns dns_cf");
expect(cmd).toContain("-d 'a.com'");
expect(cmd).toContain("-d 'b.com'");
});
it("builds renew per client", () => {
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
});
it("builds revoke per client", () => {
expect(buildRevokeCommand("certbot", "example.com")).toBe(
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
);
const acme = buildRevokeCommand("acme.sh", "example.com");
expect(acme).toContain("--revoke -d 'example.com'");
expect(acme).toContain("--remove -d 'example.com'");
});
it("validates certificate names (rejects shell metachars)", () => {
expect(isValidCertName("example.com")).toBe(true);
expect(isValidCertName("example.com-0001")).toBe(true);
expect(isValidCertName("*.example.com")).toBe(true);
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
expect(isValidCertName("")).toBe(false);
expect(isValidCertName(undefined)).toBe(false);
});
it("parses certbot certificates", () => {
const out = [
"Found the following certs:",
" Certificate Name: example.com",
" Domains: example.com www.example.com",
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
].join("\n");
const certs = parseCertbotCertificates(out);
expect(certs[0]).toMatchObject({
name: "example.com",
client: "certbot",
});
expect(certs[0].domains).toContain("www.example.com");
});
});
describe("firewall", () => {
it("builds iptables add/delete on INPUT only", () => {
expect(
buildIptablesRuleCommand("add", {
protocol: "tcp",
port: 443,
target: "ACCEPT",
}),
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
expect(
buildIptablesRuleCommand("delete", {
protocol: "udp",
port: 53,
target: "DROP",
}),
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
});
it("builds nft rules", () => {
expect(
buildNftRuleCommand("add", {
protocol: "tcp",
port: 22,
target: "ACCEPT",
}),
).toContain("add rule inet filter input tcp dport 22 accept");
});
});
describe("users", () => {
it("parses passwd for human users only", () => {
const out =
"root:x:0:0:root:/root:/bin/bash\n" +
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
const users = parsePasswd(out);
expect(users).toHaveLength(1);
expect(users[0].name).toBe("deploy");
});
it("parses sudoers membership", () => {
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
});
});
describe("health checks", () => {
it("builds tcp and http commands", () => {
const tcp = buildHealthCheckCommand({
id: "1",
name: "ssh",
type: "tcp",
target: "localhost",
port: 22,
});
expect(tcp).toContain("/dev/tcp/");
const http = buildHealthCheckCommand({
id: "2",
name: "web",
type: "http",
target: "example.com",
path: "/health",
});
expect(http).toContain("curl -s -o /dev/null");
expect(http).toContain("http://example.com/health");
});
it("parses tcp and http results", () => {
const tcp = parseHealthResult(
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
"ok 12",
);
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
const http = parseHealthResult(
{ id: "2", name: "web", type: "http", target: "h" },
"200 0.045",
);
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
const bad = parseHealthResult(
{ id: "3", name: "web", type: "http", target: "h" },
"500 0.01",
);
expect(bad.ok).toBe(false);
});
});
describe("logs", () => {
it("clamps line counts", () => {
expect(clampLines(50)).toBe(50);
expect(clampLines(99999)).toBe(2000);
expect(clampLines("abc")).toBe(200);
expect(clampLines(0)).toBe(1);
});
it("builds a quoted tail command without suppressing stderr", () => {
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
"tail -n 100 '/var/log/syslog'",
);
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import {
toFixedNum,
kibToGiB,
encodePowerShellCommand,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
describe("toFixedNum", () => {
it("rounds to the requested digit count", () => {
expect(toFixedNum(3.14159, 2)).toBe(3.14);
expect(toFixedNum(3.14159, 0)).toBe(3);
expect(toFixedNum(2.5, 0)).toBe(3);
});
it("defaults to 2 digits", () => {
expect(toFixedNum(1.23456)).toBe(1.23);
});
it("returns null for non-finite or non-number input", () => {
expect(toFixedNum(null)).toBeNull();
expect(toFixedNum(undefined)).toBeNull();
expect(toFixedNum(NaN)).toBeNull();
expect(toFixedNum(Infinity)).toBeNull();
});
});
describe("kibToGiB", () => {
it("converts kibibytes to gibibytes", () => {
expect(kibToGiB(1024 * 1024)).toBe(1);
expect(kibToGiB(0)).toBe(0);
expect(kibToGiB(2 * 1024 * 1024)).toBe(2);
});
});
describe("encodePowerShellCommand", () => {
it("UTF-16LE base64 encodes the script, decodable back to the source", () => {
const script = "Write-Output 'hello'";
const encoded = encodePowerShellCommand(script);
expect(Buffer.from(encoded, "base64").toString("utf16le")).toBe(script);
});
});
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
import {
execElevated,
ElevationError,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, stderr = "", code: number | null = 0) {
return { stdout, stderr, code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("execElevated (no force)", () => {
it("returns the direct result when the command succeeds unprivileged", async () => {
execCommand.mockResolvedValueOnce(result("hello", "", 0));
const r = await execElevated(fakeClient, "echo hello", "pw");
expect(r.usedSudo).toBe(false);
expect(r.stdout).toBe("hello");
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("does NOT escalate when stdout merely contains scary words", async () => {
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
// not should be surfaced as-is, never retried under sudo.
execCommand.mockResolvedValueOnce(
result("log line: permission denied for user foo", "", 1),
);
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
expect(r.usedSudo).toBe(false);
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("escalates when stderr indicates a permission problem", async () => {
execCommand
.mockResolvedValueOnce(result("", "Permission denied", 1))
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
expect(r.usedSudo).toBe(true);
expect(r.stdout).toBe("elevated output");
expect(execCommand).toHaveBeenCalledTimes(2);
});
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
await expect(
execElevated(fakeClient, "cat /etc/shadow", undefined),
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
});
});
describe("execElevated (forced)", () => {
it("strips the success marker from stdout", async () => {
execCommand.mockResolvedValueOnce(
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
);
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
expect(r.stdout).toBe("the real output\n");
expect(r.usedSudo).toBe(true);
});
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
execCommand.mockResolvedValueOnce(
result(
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
"",
0,
),
);
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
forceSudo: true,
});
expect(r.usedSudo).toBe(true);
expect(r.stdout).toContain("incorrect password");
});
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
execCommand.mockResolvedValueOnce(
result("", "sudo: 1 incorrect password attempt", 1),
);
await expect(
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
).rejects.toMatchObject({ code: "SUDO_FAILED" });
});
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
execCommand.mockResolvedValueOnce(
result("", "deploy is not in the sudoers file.", 1),
);
await expect(
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
).rejects.toMatchObject({ code: "NOT_SUDOER" });
});
it("throws SUDO_REQUIRED when forced without a password", async () => {
await expect(
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
).rejects.toBeInstanceOf(ElevationError);
expect(execCommand).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../../../src/backend/hosts/metrics-shared/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
"Host denied (verification failed)",
"Host key changed - please connect via Terminal to verify the new key",
])("classifies %s", (message) => {
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
});
it("does not classify ordinary authentication failures", () => {
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
false,
);
});
});
describe("host availability status", () => {
it("does not call a TCP-reachable host online before authentication", () => {
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
});
it("keeps a verified host online across later reachability checks", () => {
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
});
it("marks successful authentication online", () => {
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
});
it("downgrades failed authentication without hiding reachability", () => {
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
});
});
@@ -0,0 +1,267 @@
import { describe, expect, it, vi } from "vitest";
import {
authFailureTracker,
canStartInitialMetrics,
ConcurrentLimiter,
HostPollCache,
metricsConcurrencyFor,
} from "../../../../../src/backend/hosts/metrics-shared/state.js";
describe("auth failure tracking", () => {
it("permanently pauses polling after a host key mismatch", () => {
const hostId = 991266;
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
expect(authFailureTracker.getSkipReason(hostId)).toContain(
"host key changed",
);
authFailureTracker.reset(hostId);
});
});
describe("initial metrics admission", () => {
it("requires both an active viewer and a confirmed online status", () => {
expect(canStartInitialMetrics("online", true)).toBe(true);
expect(canStartInitialMetrics("reachable", true)).toBe(true);
expect(canStartInitialMetrics("offline", true)).toBe(false);
expect(canStartInitialMetrics(undefined, true)).toBe(false);
expect(canStartInitialMetrics("online", false)).toBe(false);
expect(canStartInitialMetrics(undefined, true, false)).toBe(true);
});
});
describe("ConcurrentLimiter", () => {
it("never exceeds max concurrent runners", async () => {
const limiter = new ConcurrentLimiter(2);
let peak = 0;
let current = 0;
const job = async () => {
current += 1;
peak = Math.max(peak, current);
await new Promise((r) => setTimeout(r, 30));
current -= 1;
};
await Promise.all([
limiter.run(job),
limiter.run(job),
limiter.run(job),
limiter.run(job),
]);
expect(peak).toBeLessThanOrEqual(2);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("runs waiters in FIFO order after a slot frees", async () => {
const limiter = new ConcurrentLimiter(1);
const order: number[] = [];
const first = limiter.run(async () => {
order.push(1);
await new Promise((r) => setTimeout(r, 20));
});
const second = limiter.run(async () => {
order.push(2);
});
const third = limiter.run(async () => {
order.push(3);
});
await Promise.all([first, second, third]);
expect(order).toEqual([1, 2, 3]);
});
it("rejects invalid maxConcurrent", () => {
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
});
describe("setLimit", () => {
it("releases queued waiters as soon as the ceiling is raised", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(1);
expect(limiter.pendingCount).toBe(3);
// Widening must drain the backlog without waiting for the running job.
limiter.setLimit(4);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
expect(limiter.pendingCount).toBe(0);
release.forEach((fn) => fn());
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
});
it("does not over-release beyond the new ceiling", async () => {
const limiter = new ConcurrentLimiter(1);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
// Later waves of woken jobs enqueue their own resolvers, so draining has
// to keep going until nothing is left rather than flushing a snapshot.
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
limiter.setLimit(3);
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(3);
expect(limiter.pendingCount).toBe(2);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
});
it("lets running work finish when the ceiling shrinks", async () => {
const limiter = new ConcurrentLimiter(4);
let running = 0;
let peak = 0;
const release: Array<() => void> = [];
const drain = async () => {
while (release.length > 0) {
release.splice(0).forEach((fn) => fn());
await new Promise((r) => setTimeout(r, 5));
}
};
const job = () =>
limiter.run(async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise<void>((r) => release.push(r));
running -= 1;
});
const jobs = [job(), job(), job(), job(), job(), job()];
await new Promise((r) => setTimeout(r, 10));
expect(peak).toBe(4);
// Shrinking never kills in-flight work; it applies to later releases.
limiter.setLimit(2);
expect(limiter.activeCount).toBe(4);
await drain();
await Promise.all(jobs);
expect(limiter.activeCount).toBe(0);
expect(limiter.pendingCount).toBe(0);
// The two queued jobs ran only after the shrink, so they never pushed
// occupancy back up to the old width.
expect(peak).toBe(4);
});
it("rejects an invalid new limit", () => {
const limiter = new ConcurrentLimiter(2);
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
expect(limiter.limit).toBe(2);
});
});
});
describe("metricsConcurrencyFor", () => {
it("keeps a floor for small installs", () => {
expect(metricsConcurrencyFor(0, {})).toBe(5);
expect(metricsConcurrencyFor(1, {})).toBe(5);
expect(metricsConcurrencyFor(60, {})).toBe(5);
});
it("scales up with the fleet", () => {
expect(metricsConcurrencyFor(200, {})).toBe(10);
expect(metricsConcurrencyFor(500, {})).toBe(25);
});
it("caps so a huge fleet cannot exhaust the host", () => {
expect(metricsConcurrencyFor(100000, {})).toBe(50);
});
it("lets an operator override the sizing", () => {
expect(metricsConcurrencyFor(1000, { METRICS_POLL_CONCURRENCY: "8" })).toBe(
8,
);
});
it("still caps an oversized override", () => {
expect(
metricsConcurrencyFor(10, { METRICS_POLL_CONCURRENCY: "9999" }),
).toBe(50);
});
it("ignores a nonsense override", () => {
expect(
metricsConcurrencyFor(500, { METRICS_POLL_CONCURRENCY: "abc" }),
).toBe(25);
});
it("sweeps 500 hosts inside a 30s interval, which the old fixed 5 could not", () => {
const POLL_MS = 400;
const hosts = 500;
const sweepAt = (c: number) => Math.ceil(hosts / c) * POLL_MS;
expect(sweepAt(5)).toBeGreaterThan(30_000);
expect(sweepAt(metricsConcurrencyFor(hosts, {}))).toBeLessThan(30_000);
});
});
describe("HostPollCache", () => {
it("returns cached host within TTL for the same user", () => {
const cache = new HostPollCache<{ id: number; name: string }>(60_000);
cache.set(1, "user-a", { id: 1, name: "alpha" });
expect(cache.get(1, "user-a")).toEqual({ id: 1, name: "alpha" });
expect(cache.get(1, "user-b")).toBeNull();
});
it("expires entries after TTL", () => {
vi.useFakeTimers();
const cache = new HostPollCache<{ id: number }>(1_000);
cache.set(7, "u", { id: 7 });
expect(cache.get(7, "u")).toEqual({ id: 7 });
vi.advanceTimersByTime(1_001);
expect(cache.get(7, "u")).toBeNull();
vi.useRealTimers();
});
it("invalidate drops a host or the whole cache", () => {
const cache = new HostPollCache<{ id: number }>(60_000);
cache.set(1, "u", { id: 1 });
cache.set(2, "u", { id: 2 });
cache.invalidate(1);
expect(cache.get(1, "u")).toBeNull();
expect(cache.get(2, "u")).toEqual({ id: 2 });
cache.invalidate();
expect(cache.get(2, "u")).toBeNull();
});
});
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { HostMetricsHistoryRepository } from "../../../../../src/backend/database/repositories/host-metrics-history-repository.js";
describe("HostMetricsHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostMetricsHistoryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_metrics_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new HostMetricsHistoryRepository(context, onWrite);
}
it("creates and lists metrics history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
hostId: 1,
cpuPercent: 12,
memPercent: 22,
diskPercent: 32,
netRxBytes: 102,
netTxBytes: 202,
});
const rows = await repo.listRange(
1,
"2026-01-01 00:00:00",
"2026-01-02 23:59:59",
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
});
it("prunes old history for a host only", async () => {
const repo = await createRepository();
await repo.pruneOlderThan(1, 1);
const rows = await repo.listRange(
1,
"2000-01-01 00:00:00",
"2999-12-31 23:59:59",
);
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
expect(
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
).toHaveLength(1);
});
});
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { HostMetricsPreferenceRepository } from "../../../../../src/backend/database/repositories/host-metrics-preference-repository.js";
describe("HostMetricsPreferenceRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<HostMetricsPreferenceRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, stats_config, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '{}', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '{}', '10.0.0.1', 22, 'root', 'password');
INSERT INTO host_metrics_preferences (
user_id, host_id, layout, created_at, updated_at
)
VALUES (
'user-1',
1,
'{"slots":[],"columns":3}',
'2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z'
);
`);
return new HostMetricsPreferenceRepository(context, onWrite);
}
it("finds a saved layout by user and host", async () => {
const repo = await createRepository();
const existing = await repo.findByUserAndHost("user-1", 1);
expect(existing?.layout).toBe('{"slots":[],"columns":3}');
expect(await repo.findByUserAndHost("user-1", 2)).toBeNull();
});
it("updates and inserts layouts with write notifications", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const updated = await repo.upsertLayout(
"user-1",
1,
'{"slots":[{"id":"cpu"}],"columns":2}',
"2026-02-01T00:00:00.000Z",
);
expect(updated).toMatchObject({
id: 1,
layout: '{"slots":[{"id":"cpu"}],"columns":2}',
updatedAt: "2026-02-01T00:00:00.000Z",
});
const created = await repo.upsertLayout(
"user-2",
2,
'{"slots":[{"id":"mem"}],"columns":1}',
"2026-03-01T00:00:00.000Z",
);
expect(created).toMatchObject({
userId: "user-2",
hostId: 2,
layout: '{"slots":[{"id":"mem"}],"columns":1}',
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
expect(writeCount).toBe(2);
});
it("updates host stats config for the owning user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await expect(
repo.updateHostStatsConfig(
"user-1",
1,
'{"enabledWidgets":["cpu","memory"]}',
),
).resolves.toBe(true);
await expect(
repo.updateHostStatsConfig("user-2", 1, '{"enabledWidgets":["disk"]}'),
).resolves.toBe(false);
expect(writeCount).toBe(1);
});
it("deletes all metric preferences for a user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertLayout("user-2", 2, '{"slots":["mem"]}');
await expect(repo.deleteByUserId("user-1")).resolves.toBe(1);
await expect(repo.deleteByUserId("missing")).resolves.toBe(0);
expect(await repo.findByUserAndHost("user-1", 1)).toBeNull();
expect((await repo.findByUserAndHost("user-2", 2))?.layout).toBe(
'{"slots":["mem"]}',
);
expect(writeCount).toBe(2);
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for Support#1216: routine status polling on a host with
// metrics disabled ran a full SSH authentication attempt on every interval,
// which fires a live 2FA push each cycle on RADIUS/Duo-backed devices with
// no active session. Spinning up the full metrics-service Express app (DB,
// SSH clients, timers, polling managers, etc.) just to drive pollHostStatus
// through two intervals is out of scope, so this asserts directly against
// the source that the status-poll path never calls into SSH auth, the same
// way login-alert-route-order.test.ts guards route registration order.
describe("metrics service status polling", () => {
it("never authenticates over SSH from pollHostStatus, on the first poll or any later one", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/backend/routes.ts"),
"utf8",
);
const pollHostStatusStart = source.indexOf("private async pollHostStatus(");
const pollHostMetricsStart = source.indexOf(
"private async pollHostMetrics(",
);
expect(pollHostStatusStart).toBeGreaterThan(-1);
expect(pollHostMetricsStart).toBeGreaterThan(pollHostStatusStart);
const pollHostStatusBody = source.slice(
pollHostStatusStart,
pollHostMetricsStart,
);
expect(pollHostStatusBody).not.toContain("withSshConnection");
expect(pollHostStatusBody).not.toContain("statusAfterAuthentication");
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect } from "vitest";
import {
parseCpuLine,
parseDarwinCpuUsageLine,
} from "../../../src/backend/widgets/cpu-collector.js";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDarwinCpuUsageLine", () => {
it("parses macOS `top -l 1` CPU usage line", () => {
const result = parseDarwinCpuUsageLine(
"CPU usage: 12.34% user, 5.66% sys, 82.00% idle",
);
expect(result?.percent).toBeCloseTo(18.0, 5);
});
it("returns undefined for unrelated lines", () => {
expect(parseDarwinCpuUsageLine("Processes: 400 total")).toBeUndefined();
});
});
@@ -0,0 +1,319 @@
import { describe, it, expect } from "vitest";
import {
parseDfLines,
findWorstMountIndex,
buildFilesystemList,
selectPrimaryFilesystem,
filterExcludedFilesystems,
mergeMonitoredFilesystems,
parseWindowsDiskJson,
parseDarwinMountTypes,
parseDarwinDfRows,
} from "../../../src/backend/widgets/disk-collector.js";
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
// Representative output shape from `df -Pk` and `mount` on a modern macOS
// (Sonoma/Sequoia, Apple Silicon) system, where the APFS root volume group
// surfaces as several synthetic system volumes alongside the real "/" and
// "/System/Volumes/Data" mounts.
const DARWIN_DF_OUTPUT =
"Filesystem 1024-blocks Used Available Capacity Mounted on\n" +
"/dev/disk3s1s1 971350180 10904432 723545416 2% /\n" +
"devfs 205 205 0 100% /dev\n" +
"/dev/disk3s6 971350180 8437248 723545416 2% /System/Volumes/VM\n" +
"/dev/disk3s2 971350180 6067864 723545416 1% /System/Volumes/Preboot\n" +
"/dev/disk3s4 971350180 16612 723545416 1% /System/Volumes/Update\n" +
"/dev/disk1s2 500098 6104 479950 2% /System/Volumes/xarts\n" +
"/dev/disk1s1 500098 5388 479950 2% /System/Volumes/iSCPreboot\n" +
"/dev/disk1s3 500098 36 479950 1% /System/Volumes/Hardware\n" +
"/dev/disk3s5 971350180 235925064 723545416 25% /System/Volumes/Data\n" +
"map auto_home 0 0 0 100% /System/Volumes/Data/home\n";
const DARWIN_MOUNT_OUTPUT =
"/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\n" +
"devfs on /dev (devfs, local, nobrowse)\n" +
"/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
"/dev/disk3s2 on /System/Volumes/Preboot (apfs, local, journaled, nobrowse)\n" +
"/dev/disk3s4 on /System/Volumes/Update (apfs, local, journaled, nobrowse)\n" +
"/dev/disk1s2 on /System/Volumes/xarts (apfs, local, noexec, journaled, noatime, nobrowse)\n" +
"/dev/disk1s1 on /System/Volumes/iSCPreboot (apfs, local, journaled, nobrowse)\n" +
"/dev/disk1s3 on /System/Volumes/Hardware (apfs, local, journaled, nobrowse)\n" +
"/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled, nobrowse)\n" +
"map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)\n";
describe("parseDarwinMountTypes", () => {
it("maps each mount point to its filesystem type", () => {
const map = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
expect(map.get("/")).toBe("apfs");
expect(map.get("/dev")).toBe("devfs");
expect(map.get("/System/Volumes/Data/home")).toBe("autofs");
});
});
describe("parseDarwinDfRows", () => {
it("parses real `df -Pk` rows and drops devfs/autofs and synthetic system volumes", () => {
const typeByMount = parseDarwinMountTypes(DARWIN_MOUNT_OUTPUT);
const rows = parseDarwinDfRows(DARWIN_DF_OUTPUT, typeByMount);
expect(rows.map((r) => r.mount)).toEqual(["/", "/System/Volumes/Data"]);
const root = rows[0];
expect(root.type).toBe("apfs");
expect(root.totalBytes).toBe(971350180 * 1024);
expect(root.usedBytes).toBe(10904432 * 1024);
expect(root.availableBytes).toBe(723545416 * 1024);
// Our percent is used/total (matches the Linux/Windows collectors), which
// differs slightly from df's own Capacity% (used/(used+available)).
expect(root.percent).toBe(1);
expect(root.usedHuman).toBeTruthy();
expect(root.totalHuman).toBeTruthy();
});
it("returns an empty list for blank output", () => {
expect(parseDarwinDfRows("", new Map())).toEqual([]);
});
});
describe("parseWindowsDiskJson", () => {
it("handles ConvertTo-Json collapsing a single result to an object", () => {
const rows = parseWindowsDiskJson(
'{"drive":"C:","total":1000000000,"free":400000000}',
);
expect(rows).toEqual([{ drive: "C:", total: 1000000000, free: 400000000 }]);
});
it("parses an array of drives", () => {
const rows = parseWindowsDiskJson(
'[{"drive":"C:","total":1000,"free":400},{"drive":"D:","total":2000,"free":1900}]',
);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({ drive: "D:", total: 2000, free: 1900 });
});
it("drops drives with a zero or missing total", () => {
const rows = parseWindowsDiskJson(
'[{"drive":"C:","total":0,"free":0},{"drive":"D:","total":100,"free":50}]',
);
expect(rows).toHaveLength(1);
expect(rows[0].drive).toBe("D:");
});
it("returns an empty array for blank or invalid output", () => {
expect(parseWindowsDiskJson("")).toEqual([]);
expect(parseWindowsDiskJson("not json")).toEqual([]);
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import {
parseNvidiaSmiGpus,
parseNvidiaSmiOutput,
parseNvidiaSmiProcesses,
} from "../../../src/backend/widgets/gpu-collector.js";
const RTX_UUID = "GPU-3b8f2c1a-5d4e-7f60-8a9b-0c1d2e3f4a5b";
const H100_UUID = "GPU-9a8b7c6d-5e4f-3a2b-1c0d-e9f8a7b6c5d4";
// index, uuid, driver_version, utilization.gpu, memory.used, memory.total,
// temperature.gpu, power.draw, power.limit, fan.speed, name
const GPU_QUERY_OUTPUT = [
`0, ${RTX_UUID}, 550.54.14, 37, 8123, 24564, 61, 182.47, 450.00, 45, NVIDIA GeForce RTX 4090`,
`1, ${H100_UUID}, 550.54.14, 0, 1, 81559, 34, 62.10, 700.00, [N/A], NVIDIA H100 80GB HBM3`,
"",
].join("\n");
// gpu_uuid, pid, used_memory, process_name
const APPS_QUERY_OUTPUT = [
`${RTX_UUID}, 12345, 7890, /usr/bin/python3`,
`${H100_UUID}, 23456, [N/A], /opt/app/worker, gpu, v2`,
"",
].join("\n");
const DRIVER_FAILURE =
"NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver. Make sure that the latest NVIDIA driver is installed and running.\n";
const RTX = {
index: 0,
uuid: RTX_UUID,
name: "NVIDIA GeForce RTX 4090",
driverVersion: "550.54.14",
utilizationPercent: 37,
memoryUsedMiB: 8123,
memoryTotalMiB: 24564,
memoryPercent: 33.1,
temperatureCelsius: 61,
powerDrawWatts: 182.5,
powerLimitWatts: 450,
fanPercent: 45,
};
const H100 = {
index: 1,
uuid: H100_UUID,
name: "NVIDIA H100 80GB HBM3",
driverVersion: "550.54.14",
utilizationPercent: 0,
memoryUsedMiB: 1,
memoryTotalMiB: 81559,
memoryPercent: 0,
temperatureCelsius: 34,
powerDrawWatts: 62.1,
powerLimitWatts: 700,
fanPercent: null,
};
describe("gpu collector", () => {
describe("parseNvidiaSmiGpus", () => {
it("parses one device per row of --query-gpu output", () => {
expect(parseNvidiaSmiGpus(GPU_QUERY_OUTPUT)).toEqual([RTX, H100]);
});
it("maps unsupported and unavailable readings to null", () => {
const [gpu] = parseNvidiaSmiGpus(
"0, GPU-aaaa, 470.182.03, [N/A], 512, [N/A], [Not Supported], [Not Supported], [N/A], [Unknown Error], Tesla K80\n",
);
expect(gpu).toEqual({
index: 0,
uuid: "GPU-aaaa",
name: "Tesla K80",
driverVersion: "470.182.03",
utilizationPercent: null,
memoryUsedMiB: 512,
memoryTotalMiB: null,
memoryPercent: null,
temperatureCelsius: null,
powerDrawWatts: null,
powerLimitWatts: null,
fanPercent: null,
});
});
it("keeps a device name that contains commas intact", () => {
const [gpu] = parseNvidiaSmiGpus(
"0, GPU-bbbb, 535.0, 5, 10, 100, 40, 20, 70, 30, Quadro RTX, Mobile, Max-Q\n",
);
expect(gpu.name).toBe("Quadro RTX, Mobile, Max-Q");
expect(gpu.fanPercent).toBe(30);
});
it("ignores driver error text printed to stdout", () => {
expect(parseNvidiaSmiGpus(DRIVER_FAILURE)).toEqual([]);
expect(parseNvidiaSmiGpus("No devices were found\n")).toEqual([]);
});
it("skips a csv header row", () => {
const header =
"index, uuid, driver_version, utilization.gpu [%], memory.used [MiB], memory.total [MiB], temperature.gpu, power.draw [W], power.limit [W], fan.speed [%], name\n";
expect(parseNvidiaSmiGpus(header + GPU_QUERY_OUTPUT)).toEqual([
RTX,
H100,
]);
});
});
describe("parseNvidiaSmiProcesses", () => {
it("attaches each process to the GPU with the matching uuid", () => {
expect(parseNvidiaSmiProcesses(APPS_QUERY_OUTPUT, [RTX, H100])).toEqual([
{
gpuIndex: 0,
pid: 12345,
name: "/usr/bin/python3",
memoryUsedMiB: 7890,
},
{
gpuIndex: 1,
pid: 23456,
name: "/opt/app/worker, gpu, v2",
memoryUsedMiB: null,
},
]);
});
it("keeps processes whose uuid matches no listed GPU", () => {
expect(
parseNvidiaSmiProcesses("GPU-unknown, 77, 100, /bin/app\n", [RTX]),
).toEqual([
{ gpuIndex: null, pid: 77, name: "/bin/app", memoryUsedMiB: 100 },
]);
});
it("ignores driver error text printed to stdout", () => {
expect(parseNvidiaSmiProcesses(DRIVER_FAILURE, [RTX])).toEqual([]);
});
it("skips a csv header row", () => {
expect(
parseNvidiaSmiProcesses(
"gpu_uuid, pid, used_gpu_memory [MiB], process_name\n",
[RTX],
),
).toEqual([]);
});
});
describe("parseNvidiaSmiOutput", () => {
it("splits the combined command output into devices and processes", () => {
const result = parseNvidiaSmiOutput(
`${GPU_QUERY_OUTPUT}---\n${APPS_QUERY_OUTPUT}`,
);
expect(result.source).toBe("nvidia-smi");
expect(result.gpus).toEqual([RTX, H100]);
expect(result.processes.map((p) => [p.gpuIndex, p.pid])).toEqual([
[0, 12345],
[1, 23456],
]);
});
it("reports no source when nvidia-smi is not installed", () => {
expect(parseNvidiaSmiOutput("")).toEqual({
source: "none",
gpus: [],
processes: [],
});
});
it("reports no source when the driver cannot be reached", () => {
expect(
parseNvidiaSmiOutput(`${DRIVER_FAILURE}---\n${DRIVER_FAILURE}`),
).toEqual({ source: "none", gpus: [], processes: [] });
});
});
});
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
counterRate,
parseNetworkCounters,
parseDarwinIfconfig,
parseDarwinNetstat,
parseWindowsAdapterJson,
} from "../../../src/backend/widgets/network-collector.js";
const PROC_NET = `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
eth0: 1024 1 0 0 0 0 0 0 2048 2 0 0 0 0 0 0
lo: 4096 4 0 0 0 0 0 0 4096 4 0 0 0 0 0 0`;
describe("network counters", () => {
it("parses Linux proc counters", () => {
expect(parseNetworkCounters(PROC_NET).get("eth0")).toEqual({
rx: "1024",
tx: "2048",
});
});
it("calculates bytes per second and rejects counter resets", () => {
expect(counterRate("1000", "2500", 0.5)).toBe(3000);
expect(counterRate("2500", "1000", 0.5)).toBeNull();
});
});
const DARWIN_IFCONFIG = `lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
inet 127.0.0.1 netmask 0xff000000
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether ac:de:48:00:11:22
inet 192.168.1.5 netmask 0xffffff00 broadcast 192.168.1.255
status: active
en1: flags=8822<BROADCAST,SMART,SIMPLEX,MULTICAST> mtu 1500
ether ac:de:48:00:11:23
status: inactive`;
const DARWIN_NETSTAT = `Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll
lo0 16384 <Link#1> 466472 0 62511610 466472 0 62511610 0
lo0 16384 127 127.0.0.1 466472 - 62511610 466472 - 62511610 -
en0 1500 <Link#4> ac:de:48:00:11:22 123456 0 987654321 65432 0 123456789 0
en0 1500 192.168.1 192.168.1.5 123456 - 987654321 65432 - 123456789 -`;
describe("darwin network parsing", () => {
it("parses interface addresses and up/down state from ifconfig", () => {
const map = parseDarwinIfconfig(DARWIN_IFCONFIG);
expect(map.has("lo0")).toBe(false);
expect(map.get("en0")).toEqual({ ip: "192.168.1.5", state: "UP" });
expect(map.get("en1")).toEqual({ ip: "", state: "DOWN" });
});
it("parses byte counters from the Link-layer netstat -ib row", () => {
const counters = parseDarwinNetstat(DARWIN_NETSTAT);
expect(counters.has("lo0")).toBe(false);
expect(counters.get("en0")).toEqual({
rx: "987654321",
tx: "123456789",
});
});
});
describe("parseWindowsAdapterJson", () => {
it("handles ConvertTo-Json collapsing a single result to an object", () => {
const rows = parseWindowsAdapterJson(
'{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000}',
);
expect(rows).toEqual([
{ name: "Ethernet", ip: "10.0.0.5", state: "UP", rx: "1000", tx: "2000" },
]);
});
it("parses an array of adapters", () => {
const rows = parseWindowsAdapterJson(
'[{"name":"Ethernet","ip":"10.0.0.5","state":"UP","rx":1000,"tx":2000},' +
'{"name":"Wi-Fi","ip":null,"state":"UP","rx":500,"tx":700}]',
);
expect(rows).toHaveLength(2);
expect(rows[1]).toEqual({
name: "Wi-Fi",
ip: "",
state: "UP",
rx: "500",
tx: "700",
});
});
it("returns an empty array for blank or invalid output", () => {
expect(parseWindowsAdapterJson("")).toEqual([]);
expect(parseWindowsAdapterJson("not json")).toEqual([]);
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
parseSensorsOutput,
parseSysfsThermalOutput,
} from "../../../src/backend/widgets/temperature-collector.js";
describe("temperature collectors", () => {
it("parses sysfs thermal zone output", () => {
const result = parseSysfsThermalOutput(
"x86_pkg_temp\t51000\nacpitz\t42\nbad\tnope\n",
);
expect(result).toEqual([
{ label: "x86_pkg_temp", celsius: 51 },
{ label: "acpitz", celsius: 42 },
]);
});
it("parses lm-sensors temperature lines", () => {
const result = parseSensorsOutput(`
coretemp-isa-0000
Adapter: ISA adapter
Package id 0: +52.0°C (high = +80.0°C, crit = +100.0°C)
Core 0: +48.5°C
fan1: 1200 RPM
`);
expect(result).toEqual([
{ label: "coretemp-isa-0000: Package id 0", celsius: 52 },
{ label: "coretemp-isa-0000: Core 0", celsius: 48.5 },
]);
});
it("keeps duplicate sensor names distinct across devices", () => {
const result = parseSensorsOutput(`
nvme-pci-0100
Composite: +41.0°C
nvme-pci-0200
Composite: +52.0°C
`);
expect(result).toEqual([
{ label: "nvme-pci-0100: Composite", celsius: 41 },
{ label: "nvme-pci-0200: Composite", celsius: 52 },
]);
});
});
@@ -0,0 +1,108 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import type { ServerMetrics } from "@/main-axios";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const SAMPLE = {
cpu: { percent: 12, cores: 8, load: [0.5, 0.4, 0.3] },
memory: { percent: 40, usedGiB: 12.8, totalGiB: 32 },
disk: { percent: 55 },
gpu: {
source: "nvidia-smi",
gpus: [
{
index: 0,
uuid: "GPU-0",
name: "NVIDIA GeForce RTX 2070 SUPER",
driverVersion: "580.173.02",
utilizationPercent: 0,
memoryUsedMiB: 5689,
memoryTotalMiB: 8192,
memoryPercent: 69.4,
temperatureCelsius: 35,
powerDrawWatts: 24,
powerLimitWatts: 215,
fanPercent: 0,
},
],
processes: [],
},
lastChecked: "2026-09-18T07:00:00.000Z",
} as ServerMetrics;
vi.mock("@/main-axios.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("@/main-axios.ts")>()),
getServerStatusById: vi.fn(async () => ({ status: "online" })),
getServerMetricsById: vi.fn(async () => SAMPLE),
startMetricsPolling: vi.fn(async () => ({ viewerSessionId: "viewer-1" })),
stopMetricsPolling: vi.fn(async () => undefined),
sendMetricsHeartbeat: vi.fn(async () => true),
getSnippets: vi.fn(async () => []),
getSSHHosts: vi.fn(async () => []),
logActivity: vi.fn(async () => undefined),
}));
vi.mock("@/contexts/UiPreferencesContext", async (importOriginal) => ({
...(await importOriginal<typeof import("@/contexts/UiPreferencesContext")>()),
useAreaPreferences: () => ({ columns: 3 }),
}));
vi.mock("@/hooks/use-confirmation.ts", () => ({
useConfirmation: () => ({ confirmWithToast: vi.fn() }),
}));
vi.mock("../../src/frontend/hooks/useHostMetricsPreferences.ts", () => ({
useHostMetricsPreferences: () => ({
layout: {
slots: [{ id: "gpu", order: 0, colSpan: 2, height: null }],
columns: 3,
},
setLayout: vi.fn(),
loaded: true,
}),
}));
import { HostMetricsTab } from "../../src/frontend/HostMetricsTab";
beforeAll(() => {
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
});
afterEach(cleanup);
describe("HostMetricsTab", () => {
it("draws the trend lines from the first metrics sample, before the next poll", async () => {
render(
<HostMetricsTab
hostConfig={{
id: 1,
name: "gpu-box",
ip: "10.0.0.5",
username: "admin",
port: 22,
authType: "password",
statsConfig: JSON.stringify({
metricsEnabled: true,
metricsInterval: 30,
enabledWidgets: ["gpu"],
}),
}}
/>,
);
const trend = await screen.findByRole(
"figure",
{ name: "hostMetrics.gpu.memory" },
{ timeout: 5000 },
);
expect(trend.querySelector("svg")).not.toBeNull();
});
});
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import type { ServerMetrics } from "@/main-axios";
import type { GpuMetrics } from "@/types/stats-widgets";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { GpuCard } from "../../../src/frontend/cards/GpuCard";
const GPU_METRICS: GpuMetrics = {
source: "nvidia-smi",
gpus: [
{
index: 0,
uuid: "GPU-rtx",
name: "NVIDIA GeForce RTX 4090",
driverVersion: "550.54.14",
utilizationPercent: 37,
memoryUsedMiB: 8123,
memoryTotalMiB: 24564,
memoryPercent: 33.1,
temperatureCelsius: 61,
powerDrawWatts: 182.4,
powerLimitWatts: 450,
fanPercent: 45,
},
{
index: 1,
uuid: "GPU-h100",
name: "NVIDIA H100 80GB HBM3",
driverVersion: "550.54.14",
utilizationPercent: 0,
memoryUsedMiB: 1,
memoryTotalMiB: 81559,
memoryPercent: 0,
temperatureCelsius: 34,
powerDrawWatts: null,
powerLimitWatts: 700,
fanPercent: null,
},
],
processes: [
{
gpuIndex: 0,
pid: 12345,
name: "/usr/bin/python3",
memoryUsedMiB: 7890,
},
{ gpuIndex: 1, pid: 23456, name: "worker", memoryUsedMiB: null },
],
};
function renderCard(gpu: GpuMetrics | undefined, gpuHistories = {}) {
const metrics = { gpu } as ServerMetrics;
render(<GpuCard metrics={metrics} gpuHistories={gpuHistories} />);
}
afterEach(cleanup);
describe("GpuCard", () => {
it("explains that no NVIDIA GPU was found", () => {
renderCard({ source: "none", gpus: [], processes: [] });
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
});
it("treats metrics from a backend without gpu support as no GPU", () => {
renderCard(undefined);
expect(screen.getByText("hostMetrics.gpu.noDevice")).toBeInTheDocument();
});
it("shows the readings of every GPU", () => {
renderCard(GPU_METRICS);
expect(screen.getByText("NVIDIA GeForce RTX 4090")).toBeInTheDocument();
expect(screen.getByText("NVIDIA H100 80GB HBM3")).toBeInTheDocument();
expect(screen.getByText("7.9/24.0G")).toBeInTheDocument();
expect(screen.getByText("0.0/79.6G")).toBeInTheDocument();
expect(screen.getByText("61°C")).toBeInTheDocument();
expect(screen.getByText("182/450W")).toBeInTheDocument();
expect(screen.getByText("N/A/700W")).toBeInTheDocument();
expect(screen.getByText("45%")).toBeInTheDocument();
expect(screen.queryByText("hostMetrics.gpu.noDevice")).toBeNull();
});
it("draws each GPU's history as labelled utilization and VRAM trends", () => {
renderCard(
{ ...GPU_METRICS, gpus: [GPU_METRICS.gpus[0]] },
{ 0: { utilization: [10, 20], memory: [30, 31] } },
);
for (const name of [
"hostMetrics.gpu.utilization",
"hostMetrics.gpu.memory",
]) {
const trend = screen.getByRole("figure", { name });
expect(trend.querySelector("svg")).not.toBeNull();
}
});
it("lists GPU processes by executable name with the GPU and VRAM they hold", () => {
renderCard(GPU_METRICS);
const python = screen.getByText("python3");
expect(python).toHaveAttribute("title", "/usr/bin/python3");
expect(screen.getByText("7.7G · GPU 0")).toBeInTheDocument();
expect(screen.getByText("N/A · GPU 1")).toBeInTheDocument();
});
it("says so when no process is using a GPU", () => {
renderCard({ ...GPU_METRICS, processes: [] });
expect(screen.getByText("hostMetrics.gpu.noProcesses")).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { GpuDevice } from "@/types/stats-widgets";
import { appendGpuHistories } from "../../../src/frontend/cards/gpu-history";
function gpu(
index: number,
utilizationPercent: number | null,
memoryPercent: number | null,
): GpuDevice {
return {
index,
uuid: `GPU-${index}`,
name: `GPU ${index}`,
driverVersion: "550.54.14",
utilizationPercent,
memoryUsedMiB: null,
memoryTotalMiB: null,
memoryPercent,
temperatureCelsius: null,
powerDrawWatts: null,
powerLimitWatts: null,
fanPercent: null,
};
}
describe("appendGpuHistories", () => {
it("records utilization and memory in a separate series per GPU", () => {
const first = appendGpuHistories({}, [gpu(0, 10, 20), gpu(1, 90, 5)], 30);
const second = appendGpuHistories(
first,
[gpu(0, 15, 25), gpu(1, 80, 6)],
30,
);
expect(second).toEqual({
0: { utilization: [10, 15], memory: [20, 25] },
1: { utilization: [90, 80], memory: [5, 6] },
});
});
it("keeps only the newest samples once the series is full", () => {
let histories = {};
for (const value of [1, 2, 3, 4]) {
histories = appendGpuHistories(histories, [gpu(0, value, value)], 3);
}
expect(histories).toEqual({
0: { utilization: [2, 3, 4], memory: [2, 3, 4] },
});
});
it("records an unavailable reading as zero", () => {
expect(appendGpuHistories({}, [gpu(0, null, null)], 30)).toEqual({
0: { utilization: [0], memory: [0] },
});
});
it("drops the series of a GPU that is no longer reported", () => {
const before = appendGpuHistories({}, [gpu(0, 10, 10), gpu(1, 20, 20)], 30);
expect(appendGpuHistories(before, [gpu(0, 11, 11)], 30)).toEqual({
0: { utilization: [10, 11], memory: [10, 11] },
});
expect(appendGpuHistories(before, undefined, 30)).toEqual({});
});
});
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const statsApiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }));
const remoteStatsApiMock = vi.hoisted(() => ({ get: vi.fn() }));
const sshHostApiMock = vi.hoisted(() => ({ get: vi.fn() }));
const resolveConnectionOriginMock = vi.hoisted(() => vi.fn());
vi.mock("@/main-axios", () => ({
statsApi: statsApiMock,
sshHostApi: sshHostApiMock,
getRemoteStatsApi: () => remoteStatsApiMock,
isElectron: () => true,
handleApiError: vi.fn(),
}));
vi.mock("@/lib/hosts-request-cache", () => ({
getCachedServerStatuses: (loader: () => Promise<unknown>) => loader(),
}));
vi.mock("@/lib/connection-origin", () => ({
resolveConnectionOrigin: resolveConnectionOriginMock,
}));
import {
getAllServerStatuses,
getServerMetricsById,
sendMetricsHeartbeat,
} from "../../../../src/ui/api/host-metrics-status-api";
beforeEach(() => {
vi.clearAllMocks();
window.electronAPI = {
invoke: vi.fn(async (channel: string) =>
channel === "get-remote-sync-config"
? { serverUrl: "https://termix.example.test" }
: null,
),
} as unknown as NonNullable<typeof window.electronAPI>;
statsApiMock.get.mockResolvedValue({ data: { 1: { status: "online" } } });
remoteStatsApiMock.get.mockResolvedValue({
data: { 2: { status: "reachable" } },
});
});
describe("status check origin routing", () => {
it("only asks the embedded backend to poll local-origin hosts", async () => {
sshHostApiMock.get.mockResolvedValue({
data: [
{ id: 1, connectionOrigin: "local" },
{ id: 2, connectionOrigin: "remote" },
],
});
resolveConnectionOriginMock.mockImplementation(async (host) =>
host.connectionOrigin === "local" ? "local" : "remote",
);
await expect(getAllServerStatuses()).resolves.toEqual({
1: { status: "online" },
2: { status: "reachable" },
});
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
timeout: 2000,
params: { hostIds: "1" },
__silentRetry: true,
});
});
it("sends an empty allowlist when every host uses the remote server", async () => {
sshHostApiMock.get.mockResolvedValue({
data: [{ id: 2, connectionOrigin: "remote" }],
});
resolveConnectionOriginMock.mockResolvedValue("remote");
await getAllServerStatuses();
expect(statsApiMock.get).toHaveBeenCalledWith("/status", {
timeout: 2000,
params: { hostIds: "" },
__silentRetry: true,
});
});
});
describe("metrics request coalescing", () => {
it("shares simultaneous reads for the same host", async () => {
statsApiMock.get.mockResolvedValueOnce({
status: 200,
data: { cpu: { percent: 12 } },
});
const [first, second] = await Promise.all([
getServerMetricsById(991),
getServerMetricsById(991),
]);
expect(first).toEqual({ cpu: { percent: 12 } });
expect(second).toBe(first);
expect(statsApiMock.get).toHaveBeenCalledOnce();
});
});
describe("metrics viewer heartbeat", () => {
it("reports a swept viewer session without throwing or logging", async () => {
statsApiMock.post.mockResolvedValueOnce({ status: 404 });
await expect(sendMetricsHeartbeat("expired-viewer")).resolves.toBe(false);
expect(statsApiMock.post).toHaveBeenCalledWith(
"/metrics/heartbeat",
{ viewerSessionId: "expired-viewer" },
{ validateStatus: expect.any(Function) },
);
});
it("keeps a live viewer session", async () => {
statsApiMock.post.mockResolvedValueOnce({ status: 200 });
await expect(sendMetricsHeartbeat("live-viewer")).resolves.toBe(true);
});
});
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff. Show more