mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -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.
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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
-1
@@ -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;
|
||||
+4
-4
@@ -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;
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 {
|
||||
+3
-3
@@ -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 {
|
||||
+5
-5
@@ -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
-1
@@ -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";
|
||||
+3
-3
@@ -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. */
|
||||
+5
-5
@@ -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(
|
||||
+3
-3
@@ -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 {
|
||||
+5
-5
@@ -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") {
|
||||
+3
-3
@@ -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
-1
@@ -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";
|
||||
|
||||
+4
-4
@@ -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";
|
||||
+3
-3
@@ -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
-1
@@ -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 {
|
||||
+3
-3
@@ -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 {
|
||||
+2
-2
@@ -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;
|
||||
+5
-5
@@ -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[];
|
||||
+43
-40
@@ -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
-1
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
+4
-4
@@ -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;
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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)$/;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
+1
-1
@@ -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
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+2
-2
@@ -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
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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);
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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();
|
||||
});
|
||||
});
|
||||
+80
@@ -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);
|
||||
});
|
||||
});
|
||||
+119
@@ -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
Reference in new issue
Block a user