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

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

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ import {
} from "../../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../automations/src/backend/routes.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js";
import { getTool } from "./catalog.js";
import {
@@ -27,12 +27,10 @@ vi.mock("../../../../../src/backend/hosts/host-resolver.js", () => ({
}));
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
vi.mock("@termix/plugin-sdk/host-commands", async (importOriginal) => ({
...(await importOriginal<object>()),
execCommand: (...args: unknown[]) => execCommand(...args),
}));
vi.mock("../../../src/backend/ssh.js", () => ({
withSshConnection: async (
_host: unknown,
@@ -2,11 +2,11 @@ import {
DEFAULT_STEP_TIMEOUT_MS,
type Step,
} from "../../../../../src/types/automations.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js";
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
@@ -2,7 +2,7 @@ import type { AutomationDefinition } from "../../../../src/types/automations.js"
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "./ssh.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { onDockerEvent } from "./triggers.js";
+18 -2
View File
@@ -367,18 +367,34 @@ export function subscribeAutomationTriggers(): void {
if (unsubscribers.length > 0) return;
unsubscribers = [
pluginEvents.on(TOPICS.hostMetrics, (payload) =>
pluginEvents.on("plugin.host-metrics.snapshot", (payload) =>
onMetrics(payload as MetricEvent),
),
pluginEvents.on(TOPICS.hostStatus, (payload) =>
onStatus(payload as StatusEvent),
),
pluginEvents.on(TOPICS.hostHealthCheck, (payload) =>
pluginEvents.on("plugin.host-metrics.health-check", (payload) =>
onHealthCheck(payload as HealthEvent),
),
pluginEvents.on(TOPICS.internalEvent, (payload) =>
onInternalEvent(payload as InternalEvent),
),
// An SSH login seen by the terminal, which host-metrics used to relay.
pluginEvents.on(TOPICS.hostLogin, (payload) => {
const login = payload as {
hostId: number;
userId: string;
sshUser?: string;
fromIp?: string;
};
if (!login?.userId) return;
return onInternalEvent({
event: "user_login",
userId: login.userId,
hostId: login.hostId,
details: { sshUser: login.sshUser, fromIp: login.fromIp },
});
}),
];
}
@@ -44,7 +44,7 @@ import {
ConnectionScreen,
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { useConnectionRetry } from "@termix/plugin-sdk/frontend";
import { invokeAction, useConnectionRetry } from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts";
import {
listSSHFiles,
@@ -78,12 +78,11 @@ import {
} from "./api/file-manager-data-api";
import {
logActivity,
getServerMetricsById,
transferToHost,
addTransferRecent,
type TransferMethodPreference,
type DiskFilesystem,
} from "@/main-axios.ts";
import type { DiskFilesystem, HostDiskInfo } from "./disk-info";
import {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
@@ -3262,19 +3261,17 @@ function FileManagerContent({
useEffect(() => {
if (currentHost?.id) {
loadPinnedFiles();
getServerMetricsById(currentHost.id)
.then((metrics) => {
if (
metrics?.disk?.percent != null &&
metrics.disk.usedHuman &&
metrics.disk.totalHuman
) {
// Undefined while the host-metrics plugin is off: no disk bar then.
invokeAction("host-metrics.disk", currentHost.id)
.then((result) => {
const disk = result as HostDiskInfo | null | undefined;
if (disk?.percent != null && disk.usedHuman && disk.totalHuman) {
setDiskInfo({
usedHuman: metrics.disk.usedHuman,
totalHuman: metrics.disk.totalHuman,
percent: metrics.disk.percent,
mount: metrics.disk.mount ?? null,
filesystems: metrics.disk.filesystems ?? [],
usedHuman: disk.usedHuman,
totalHuman: disk.totalHuman,
percent: disk.percent,
mount: disk.mount ?? null,
filesystems: disk.filesystems ?? [],
});
}
})
@@ -27,7 +27,7 @@ import {
removeFolderShortcut,
} from "./api/file-manager-data-api";
import { listSSHFiles } from "./api/ssh-file-operations-api";
import { type DiskFilesystem } from "@/main-axios.ts";
import type { DiskFilesystem } from "./disk-info";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
import {
@@ -0,0 +1,22 @@
/** One mounted filesystem, as the host-metrics plugin reports it. */
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface HostDiskInfo {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
mount: string | null;
filesystems?: DiskFilesystem[];
}
+6 -2
View File
@@ -1,14 +1,18 @@
# Host Metrics
Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).
Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard). Other plugins add manager cards through the `host-metrics.managers` slot (Tailscale does).
The host list's online and offline dot is core's own status check, not this plugin. A working metrics login is what turns a reachable host online.
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/backend/ activate(ctx) / deactivate(), the poller and the routes
src/frontend/ the UI it registers
src/shared/ types both sides use
migrations/ adopts core's four metrics tables (sqlite, postgres, mysql)
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
+88 -3
View File
@@ -182,7 +182,8 @@
"wgLastHandshake": "Last handshake",
"wgHandshakeNever": "Never",
"wgTransfer": "Transfer"
}
},
"snippetsUnavailable": "The Snippets plugin is off, so quick actions cannot run."
},
"metricsHistory": {
"title": "Metrics History",
@@ -223,7 +224,9 @@
"noQuickActions": "No quick actions yet.",
"buttonLabel": "Button label",
"selectSnippetPlaceholder": "Select snippet...",
"addActionBtn": "Add Action"
"addActionBtn": "Add Action",
"hostNotFound": "Host not found",
"loadingHost": "Loading host..."
},
"onboarding": {
"feature_metrics": "Metrics and alerts",
@@ -238,9 +241,91 @@
"noData": "No data available",
"showCurrentValue": "Show Current Value",
"chartMetric": "Metric",
"metricRange": "Range"
"metricRange": "Range",
"host": "Host",
"loading": "Loading...",
"metricCpu": "CPU",
"metricDisk": "Disk",
"metricHostname": "Hostname",
"metricKernel": "Kernel",
"metricMemory": "Memory",
"metricNetwork": "Network",
"metricOs": "OS",
"metricProcesses": "Processes",
"metricProcessesRunning": "running",
"metricProcessesTotal": "total",
"metricUptime": "Uptime",
"metricsNotAvailable": "Metrics not available",
"noHostSelected": "No host selected",
"selectHost": "Select a host..."
},
"terminalStatus": {
"unavailable": "Live stats unavailable"
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Cancel",
"confirmRunButton": "Run",
"confirmRunMessage": "Run \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Connected"
},
"permissions": {
"use": {
"title": "Use Host Metrics",
"description": "View host metrics and use the host managers (services, packages, firewall and more) on hosts you can reach."
}
},
"settings": {
"admin": {
"metricsInterval": {
"label": "Metrics interval (seconds)",
"description": "How often a host being viewed is sampled, unless the host sets its own interval."
},
"historyRetentionDays": {
"label": "History retention (days)",
"description": "How long metrics samples are kept for the history charts."
},
"enabledForNewHosts": {
"label": "Metrics on for new hosts",
"description": "Whether new hosts start with metrics collection turned on."
}
},
"user": {
"temperatureUnit": {
"label": "Temperature unit",
"description": "How temperatures are shown in Host Metrics.",
"celsius": "Celsius",
"fahrenheit": "Fahrenheit"
}
},
"host": {
"metricsEnabled": {
"label": "Collect metrics"
},
"metricsInterval": {
"label": "Metrics interval (seconds)"
},
"enabledWidgets": {
"label": "Enabled widgets"
},
"excludedMounts": {
"label": "Excluded mounts"
},
"monitoredMounts": {
"label": "Monitored paths"
}
}
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Geen vinnige aksies nog nie.",
"buttonLabel": "Knoppie-etiket",
"selectSnippetPlaceholder": "Kies uittreksel...",
"addActionBtn": "Voeg Aksie by"
"addActionBtn": "Voeg Aksie by",
"hostNotFound": "Gasheer nie gevind nie",
"loadingHost": "Laai gasheer..."
},
"onboarding": {
"feature_metrics": "Metrieke en waarskuwings",
@@ -255,6 +257,41 @@
"noData": "Geen data beskikbaar nie",
"showCurrentValue": "Wys huidige waarde",
"chartMetric": "Metrieke",
"metricRange": "Reikwydte"
"metricRange": "Reikwydte",
"host": "Gasheer",
"loading": "Laai tans...",
"metricCpu": "SVE",
"metricDisk": "Skyf",
"metricHostname": "Gasheernaam",
"metricKernel": "Kern",
"metricMemory": "Geheue",
"metricNetwork": "Netwerk",
"metricOs": "OS",
"metricProcesses": "Prosesse",
"metricProcessesRunning": "hardloop",
"metricProcessesTotal": "totaal",
"metricUptime": "Optyd",
"metricsNotAvailable": "Metrieke nie beskikbaar nie",
"noHostSelected": "Geen gasheer gekies nie",
"selectHost": "Kies 'n gasheer..."
},
"dashboard": {
"cpu": "SVE",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Skyf"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Kanselleer",
"confirmRunButton": "Hardloop",
"confirmRunMessage": "Begin \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Verbonde"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "لا توجد إجراءات سريعة بعد.",
"buttonLabel": "تسمية الزر",
"selectSnippetPlaceholder": "اختر مقتطفًا...",
"addActionBtn": "إضافة إجراء"
"addActionBtn": "إضافة إجراء",
"hostNotFound": "المضيف غير موجود",
"loadingHost": "جارٍ تحميل المضيف..."
},
"onboarding": {
"feature_metrics": "المقاييس والتنبيهات",
@@ -255,6 +257,41 @@
"noData": "لا توجد بيانات",
"showCurrentValue": "إظهار القيمة الحالية",
"chartMetric": "المقياس",
"metricRange": "النطاق"
"metricRange": "النطاق",
"host": "المضيف",
"loading": "جار التحميل...",
"metricCpu": "CPU",
"metricDisk": "القرص",
"metricHostname": "اسم المضيف",
"metricKernel": "النواة",
"metricMemory": "الذاكرة",
"metricNetwork": "الشبكة",
"metricOs": "نظام التشغيل",
"metricProcesses": "العمليات",
"metricProcessesRunning": "قيد التشغيل",
"metricProcessesTotal": "إجمالي",
"metricUptime": "وقت التشغيل",
"metricsNotAvailable": "المقاييس غير متوفرة",
"noHostSelected": "لم يتم تحديد أي مضيف",
"selectHost": "اختيار مضيف..."
},
"dashboard": {
"cpu": "المعالج",
"ram": "الذاكرة"
},
"dashboardTab": {
"disk": "القرص"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "إلغاء",
"confirmRunButton": "تشغيل",
"confirmRunMessage": "تشغيل \"{{name}}\"؟"
}
}
},
"terminal": {
"connected": "متصل"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Все още няма бързи действия.",
"buttonLabel": "Етикет на бутона",
"selectSnippetPlaceholder": "Изберете фрагмент...",
"addActionBtn": "Добавяне на действие"
"addActionBtn": "Добавяне на действие",
"hostNotFound": "Хостът не е намерен",
"loadingHost": "Зареждане на хоста..."
},
"onboarding": {
"feature_metrics": "Показатели и сигнали",
@@ -255,6 +257,41 @@
"noData": "Няма налични данни",
"showCurrentValue": "Показване на текущата стойност",
"chartMetric": "Метричен",
"metricRange": "Диапазон"
"metricRange": "Диапазон",
"host": "Домакин",
"loading": "Зареждане...",
"metricCpu": "Процесор",
"metricDisk": "Диск",
"metricHostname": "Име на хост",
"metricKernel": "Ядро",
"metricMemory": "Памет",
"metricNetwork": "Мрежа",
"metricOs": "ОС",
"metricProcesses": "Процеси",
"metricProcessesRunning": "бягане",
"metricProcessesTotal": "общо",
"metricUptime": "Време на работа",
"metricsNotAvailable": "Показателите не са налични",
"noHostSelected": "Няма избран хост",
"selectHost": "Изберете хост..."
},
"dashboard": {
"cpu": "Процесор",
"ram": "RAM памет"
},
"dashboardTab": {
"disk": "Диск"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Отказ",
"confirmRunButton": "Бягане",
"confirmRunMessage": "Изпълни „{{name}}“?"
}
}
},
"terminal": {
"connected": "Свързан"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "এখনো কোনো দ্রুত পদক্ষেপ নেওয়া হয়নি।",
"buttonLabel": "বোতাম লেবেল",
"selectSnippetPlaceholder": "স্নিপেট নির্বাচন করুন...",
"addActionBtn": "অ্যাকশন যোগ করুন"
"addActionBtn": "অ্যাকশন যোগ করুন",
"hostNotFound": "হোস্ট খুঁজে পাওয়া যায়নি",
"loadingHost": "হোস্ট লোড হচ্ছে..."
},
"onboarding": {
"feature_metrics": "মেট্রিক্স এবং সতর্কতা",
@@ -255,6 +257,41 @@
"noData": "কোন ডেটা উপলব্ধ নেই",
"showCurrentValue": "বর্তমান মান দেখান",
"chartMetric": "মেট্রিক",
"metricRange": "পরিসর"
"metricRange": "পরিসর",
"host": "হোস্ট",
"loading": "লোড হচ্ছে...",
"metricCpu": "সিপিইউ",
"metricDisk": "ডিস্ক",
"metricHostname": "হোস্টনাম",
"metricKernel": "কার্নেল",
"metricMemory": "স্মৃতি",
"metricNetwork": "নেটওয়ার্ক",
"metricOs": "ওএস",
"metricProcesses": "প্রক্রিয়া",
"metricProcessesRunning": "দৌড়ানো",
"metricProcessesTotal": "মোট",
"metricUptime": "আপটাইম",
"metricsNotAvailable": "মেট্রিক্স উপলব্ধ নেই",
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
"selectHost": "একজন হোস্ট নির্বাচন করুন..."
},
"dashboard": {
"cpu": "সিপিইউ",
"ram": "রাম"
},
"dashboardTab": {
"disk": "ডিস্ক"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "বাতিল করুন",
"confirmRunButton": "দৌড়",
"confirmRunMessage": "\"{{name}} \" চালান?"
}
}
},
"terminal": {
"connected": "সংযুক্ত"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Encara no hi ha accions ràpides.",
"buttonLabel": "Etiqueta del botó",
"selectSnippetPlaceholder": "Selecciona un fragment...",
"addActionBtn": "Afegeix una acció"
"addActionBtn": "Afegeix una acció",
"hostNotFound": "No s'ha trobat l'amfitrió",
"loadingHost": "S'està carregant l'amfitrió..."
},
"onboarding": {
"feature_metrics": "Mètriques i alertes",
@@ -255,6 +257,41 @@
"noData": "No hi ha dades disponibles",
"showCurrentValue": "Mostra el valor actual",
"chartMetric": "Mètrica",
"metricRange": "Rang"
"metricRange": "Rang",
"host": "Amfitrió",
"loading": "S'està carregant...",
"metricCpu": "CPU",
"metricDisk": "Disc",
"metricHostname": "Nom d'amfitrió",
"metricKernel": "Nucli",
"metricMemory": "Memòria",
"metricNetwork": "Xarxa",
"metricOs": "Sistema operatiu",
"metricProcesses": "Processos",
"metricProcessesRunning": "corrent",
"metricProcessesTotal": "total",
"metricUptime": "Temps de funcionament",
"metricsNotAvailable": "Mètriques no disponibles",
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"selectHost": "Seleccioneu un amfitrió..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disc"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Cancel·la",
"confirmRunButton": "Corre",
"confirmRunMessage": "Executar \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Connectat"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Zatím žádné rychlé akce.",
"buttonLabel": "Štítek tlačítka",
"selectSnippetPlaceholder": "Vybrat úryvek...",
"addActionBtn": "Přidat akci"
"addActionBtn": "Přidat akci",
"hostNotFound": "Hostitel nenalezen",
"loadingHost": "Načítání hostitele..."
},
"onboarding": {
"feature_metrics": "Metriky a upozornění",
@@ -255,6 +257,41 @@
"noData": "Nejsou k dispozici žádná data",
"showCurrentValue": "Zobrazit aktuální hodnotu",
"chartMetric": "Metrický",
"metricRange": "Rozsah"
"metricRange": "Rozsah",
"host": "Hostitel",
"loading": "Načítání...",
"metricCpu": "Procesor",
"metricDisk": "Disk",
"metricHostname": "Název hostitele",
"metricKernel": "Jádro",
"metricMemory": "Paměť",
"metricNetwork": "Síť",
"metricOs": "OS",
"metricProcesses": "Procesy",
"metricProcessesRunning": "běh",
"metricProcessesTotal": "celkový",
"metricUptime": "Doba provozuschopnosti",
"metricsNotAvailable": "Metriky nejsou k dispozici",
"noHostSelected": "Nebyl vybrán žádný hostitel",
"selectHost": "Vyberte hostitele..."
},
"dashboard": {
"cpu": "Procesor",
"ram": "BERAN"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Zrušit",
"confirmRunButton": "Běh",
"confirmRunMessage": "Spustit „{{name}}“?"
}
}
},
"terminal": {
"connected": "Připojeno"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Ingen hurtige handlinger endnu.",
"buttonLabel": "Knapmærke",
"selectSnippetPlaceholder": "Vælg uddrag...",
"addActionBtn": "Tilføj handling"
"addActionBtn": "Tilføj handling",
"hostNotFound": "Værten blev ikke fundet",
"loadingHost": "Indlæser vært..."
},
"onboarding": {
"feature_metrics": "Målinger og advarsler",
@@ -255,6 +257,41 @@
"noData": "Ingen data tilgængelige",
"showCurrentValue": "Vis aktuel værdi",
"chartMetric": "Metrisk",
"metricRange": "Rækkevidde"
"metricRange": "Rækkevidde",
"host": "Vært",
"loading": "Indlæser...",
"metricCpu": "CPU",
"metricDisk": "Disk",
"metricHostname": "Værtsnavn",
"metricKernel": "Kernen",
"metricMemory": "Hukommelse",
"metricNetwork": "Netværk",
"metricOs": "OS",
"metricProcesses": "Processer",
"metricProcessesRunning": "løb",
"metricProcessesTotal": "total",
"metricUptime": "Oppetid",
"metricsNotAvailable": "Målinger er ikke tilgængelige",
"noHostSelected": "Ingen vært valgt",
"selectHost": "Vælg en vært..."
},
"dashboard": {
"cpu": "CPU",
"ram": "VÆDDER"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Ophæve",
"confirmRunButton": "Løbe",
"confirmRunMessage": "Kør \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Forbundet"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Noch keine Schnellaktionen.",
"buttonLabel": "Schaltflächenbeschriftung",
"selectSnippetPlaceholder": "Snippet auswählen...",
"addActionBtn": "Aktion hinzufügen"
"addActionBtn": "Aktion hinzufügen",
"hostNotFound": "Host nicht gefunden",
"loadingHost": "Host wird geladen..."
},
"onboarding": {
"feature_metrics": "Kennzahlen und Warnmeldungen",
@@ -255,6 +257,41 @@
"noData": "Keine Daten verfügbar",
"showCurrentValue": "Aktuellen Wert anzeigen",
"chartMetric": "Metrik",
"metricRange": "Bereich"
"metricRange": "Bereich",
"host": "Host",
"loading": "Wird geladen...",
"metricCpu": "CPU",
"metricDisk": "Festplatte",
"metricHostname": "Hostname",
"metricKernel": "Kernel",
"metricMemory": "Arbeitsspeicher",
"metricNetwork": "Netzwerk",
"metricOs": "Betriebssystem",
"metricProcesses": "Prozesse",
"metricProcessesRunning": "laufend",
"metricProcessesTotal": "gesamt",
"metricUptime": "Betriebszeit",
"metricsNotAvailable": "Metriken nicht verfügbar",
"noHostSelected": "Kein Host ausgewählt",
"selectHost": "Host auswählen..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Datenträger"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Abbrechen",
"confirmRunButton": "Ausführen",
"confirmRunMessage": "„{{name}}“ ausführen?"
}
}
},
"terminal": {
"connected": "Verbunden"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Δεν υπάρχουν ακόμη γρήγορες ενέργειες.",
"buttonLabel": "Ετικέτα κουμπιού",
"selectSnippetPlaceholder": "Επιλογή αποσπάσματος...",
"addActionBtn": "Προσθήκη ενέργειας"
"addActionBtn": "Προσθήκη ενέργειας",
"hostNotFound": "Δεν βρέθηκε κεντρικός υπολογιστής",
"loadingHost": "Φόρτωση κεντρικού υπολογιστή..."
},
"onboarding": {
"feature_metrics": "Μετρήσεις και ειδοποιήσεις",
@@ -255,6 +257,41 @@
"noData": "Δεν υπάρχουν διαθέσιμα δεδομένα",
"showCurrentValue": "Εμφάνιση τρέχουσας τιμής",
"chartMetric": "Μετρικός",
"metricRange": "Σειρά"
"metricRange": "Σειρά",
"host": "Πλήθος",
"loading": "Φόρτωση...",
"metricCpu": "ΚΜΕ",
"metricDisk": "Δίσκος",
"metricHostname": "Όνομα κεντρικού υπολογιστή",
"metricKernel": "Πυρήνας",
"metricMemory": "Μνήμη",
"metricNetwork": "Δίκτυο",
"metricOs": "Λειτουργικό σύστημα",
"metricProcesses": "Διαδικασίες",
"metricProcessesRunning": "τρέξιμο",
"metricProcessesTotal": "σύνολο",
"metricUptime": "Χρόνος λειτουργίας",
"metricsNotAvailable": "Οι μετρήσεις δεν είναι διαθέσιμες",
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"selectHost": "Επιλέξτε έναν οικοδεσπότη..."
},
"dashboard": {
"cpu": "ΚΜΕ",
"ram": "ΕΜΒΟΛΟ"
},
"dashboardTab": {
"disk": "Δίσκος"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Ματαίωση",
"confirmRunButton": "Τρέξιμο",
"confirmRunMessage": "Εκτέλεση \"{{name}}\";"
}
}
},
"terminal": {
"connected": "Συνδεδεμένος"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Aún no hay acciones rápidas.",
"buttonLabel": "Etiqueta del botón",
"selectSnippetPlaceholder": "Seleccionar fragmento...",
"addActionBtn": "Añadir acción"
"addActionBtn": "Añadir acción",
"hostNotFound": "Host no encontrado",
"loadingHost": "Cargando host..."
},
"onboarding": {
"feature_metrics": "Métricas y alertas",
@@ -255,6 +257,41 @@
"noData": "Sin datos disponibles",
"showCurrentValue": "Mostrar valor actual",
"chartMetric": "Métrica",
"metricRange": "Rango"
"metricRange": "Rango",
"host": "Host",
"loading": "Cargando...",
"metricCpu": "CPU",
"metricDisk": "Disco",
"metricHostname": "Nombre de host",
"metricKernel": "Kernel",
"metricMemory": "Memoria",
"metricNetwork": "Red",
"metricOs": "SO",
"metricProcesses": "Procesos",
"metricProcessesRunning": "en ejecución",
"metricProcessesTotal": "total",
"metricUptime": "Tiempo de actividad",
"metricsNotAvailable": "Métricas no disponibles",
"noHostSelected": "Ningún host seleccionado",
"selectHost": "Seleccionar un host..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disco"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Cancelar",
"confirmRunButton": "Ejecutar",
"confirmRunMessage": "¿Ejecutar \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Conectado"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Ei vielä nopeita toimia.",
"buttonLabel": "Painikkeen nimi",
"selectSnippetPlaceholder": "Valitse katkelma...",
"addActionBtn": "Lisää toiminto"
"addActionBtn": "Lisää toiminto",
"hostNotFound": "Isäntää ei löydy",
"loadingHost": "Ladataan isäntää..."
},
"onboarding": {
"feature_metrics": "Mittarit ja hälytykset",
@@ -255,6 +257,41 @@
"noData": "Ei tietoja saatavilla",
"showCurrentValue": "Näytä nykyinen arvo",
"chartMetric": "Metrinen",
"metricRange": "Alue"
"metricRange": "Alue",
"host": "Isäntä",
"loading": "Ladataan...",
"metricCpu": "Suoritin",
"metricDisk": "Levy",
"metricHostname": "Isäntänimi",
"metricKernel": "Ydin",
"metricMemory": "Muisti",
"metricNetwork": "Verkko",
"metricOs": "Käyttöjärjestelmä",
"metricProcesses": "Prosessit",
"metricProcessesRunning": "juokseminen",
"metricProcessesTotal": "kokonais-",
"metricUptime": "Käyttöaika",
"metricsNotAvailable": "Mittareita ei ole saatavilla",
"noHostSelected": "Ei valittua isäntää",
"selectHost": "Valitse isäntä..."
},
"dashboard": {
"cpu": "Suoritin",
"ram": "RAM-muistia"
},
"dashboardTab": {
"disk": "Levy"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Peruuttaa",
"confirmRunButton": "Juokse",
"confirmRunMessage": "Suorittaa \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Yhdistetty"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Aucune action rapide pour le moment.",
"buttonLabel": "Étiquette du bouton",
"selectSnippetPlaceholder": "Sélectionner un snippet...",
"addActionBtn": "Ajouter une action"
"addActionBtn": "Ajouter une action",
"hostNotFound": "Hôte introuvable",
"loadingHost": "Chargement de l'hôte..."
},
"onboarding": {
"feature_metrics": "Métriques et alertes",
@@ -255,6 +257,41 @@
"noData": "Aucune donnée disponible",
"showCurrentValue": "Afficher la valeur actuelle",
"chartMetric": "Métrique",
"metricRange": "Plage"
"metricRange": "Plage",
"host": "Hôte",
"loading": "Chargement…",
"metricCpu": "CPU",
"metricDisk": "Disque",
"metricHostname": "Nom d’hôte",
"metricKernel": "Noyau",
"metricMemory": "Mémoire",
"metricNetwork": "Réseau",
"metricOs": "OS",
"metricProcesses": "Processus",
"metricProcessesRunning": "en cours",
"metricProcessesTotal": "total",
"metricUptime": "Uptime",
"metricsNotAvailable": "Métriques non disponibles",
"noHostSelected": "Aucun hôte sélectionné",
"selectHost": "Sélectionner un hôte…"
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disque"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Annuler",
"confirmRunButton": "Exécuter",
"confirmRunMessage": "Exécuter « {{name}} » ?"
}
}
},
"terminal": {
"connected": "Connecté"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "אין עדיין פעולות מהירות.",
"buttonLabel": "תווית כפתור",
"selectSnippetPlaceholder": "בחר קטע...",
"addActionBtn": "הוסף פעולה"
"addActionBtn": "הוסף פעולה",
"hostNotFound": "המארח לא נמצא",
"loadingHost": "טוען מארח..."
},
"onboarding": {
"feature_metrics": "מדדים והתראות",
@@ -255,6 +257,41 @@
"noData": "אין נתונים זמינים",
"showCurrentValue": "הצג ערך נוכחי",
"chartMetric": "מֶטרִי",
"metricRange": "לָנוּעַ"
"metricRange": "לָנוּעַ",
"host": "מְאָרֵחַ",
"loading": "טְעִינָה...",
"metricCpu": "מעבד",
"metricDisk": "דִיסק",
"metricHostname": "שם מארח",
"metricKernel": "גַרעִין",
"metricMemory": "זֵכֶר",
"metricNetwork": "רֶשֶׁת",
"metricOs": "מערכת הפעלה",
"metricProcesses": "תהליכים",
"metricProcessesRunning": "רִיצָה",
"metricProcessesTotal": "סַך הַכֹּל",
"metricUptime": "זמן פעולה",
"metricsNotAvailable": "מדדים אינם זמינים",
"noHostSelected": "לא נבחר מארח",
"selectHost": "בחר מארח..."
},
"dashboard": {
"cpu": "מעבד",
"ram": "אַיִל"
},
"dashboardTab": {
"disk": "דִיסק"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "לְבַטֵל",
"confirmRunButton": "לָרוּץ",
"confirmRunMessage": "להפעיל את \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "מְחוּבָּר"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "अभी तक कोई त्वरित कार्रवाई नहीं।",
"buttonLabel": "बटन लेबल",
"selectSnippetPlaceholder": "स्निपेट चुनें...",
"addActionBtn": "कार्रवाई जोड़ें"
"addActionBtn": "कार्रवाई जोड़ें",
"hostNotFound": "होस्ट नहीं मिला",
"loadingHost": "होस्ट लोड हो रहा है..."
},
"onboarding": {
"feature_metrics": "मैट्रिक्स और अलर्ट",
@@ -255,6 +257,41 @@
"noData": "कोई डेटा उपलब्ध नहीं",
"showCurrentValue": "वर्तमान मान दिखाएं",
"chartMetric": "मीट्रिक",
"metricRange": "सीमा"
"metricRange": "सीमा",
"host": "होस्ट",
"loading": "लोड हो रहा है...",
"metricCpu": "CPU",
"metricDisk": "डिस्क",
"metricHostname": "होस्टनेम",
"metricKernel": "कर्नेल",
"metricMemory": "मेमोरी",
"metricNetwork": "नेटवर्क",
"metricOs": "ओएस",
"metricProcesses": "प्रोसेस",
"metricProcessesRunning": "चालू",
"metricProcessesTotal": "कुल",
"metricUptime": "अपटाइम",
"metricsNotAvailable": "मेट्रिक्स उपलब्ध नहीं",
"noHostSelected": "कोई होस्ट चयनित नहीं",
"selectHost": "होस्ट चुनें..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "डिस्क"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "रद्द करें",
"confirmRunButton": "चलाएँ",
"confirmRunMessage": "\"{{name}}\" चलाएँ?"
}
}
},
"terminal": {
"connected": "कनेक्टेड"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Még nincsenek gyors intézkedések.",
"buttonLabel": "Gomb felirata",
"selectSnippetPlaceholder": "Válasszon részletet...",
"addActionBtn": "Művelet hozzáadása"
"addActionBtn": "Művelet hozzáadása",
"hostNotFound": "Nem található gazdagép",
"loadingHost": "Gazdagép betöltése..."
},
"onboarding": {
"feature_metrics": "Metrikák és riasztások",
@@ -255,6 +257,41 @@
"noData": "Nincsenek elérhető adatok",
"showCurrentValue": "Aktuális érték megjelenítése",
"chartMetric": "Metrika",
"metricRange": "Hatótávolság"
"metricRange": "Hatótávolság",
"host": "Házigazda",
"loading": "Terhelés...",
"metricCpu": "CPU",
"metricDisk": "Korong",
"metricHostname": "Gazdagépnév",
"metricKernel": "Kernel",
"metricMemory": "Memória",
"metricNetwork": "Hálózat",
"metricOs": "operációs rendszer",
"metricProcesses": "Folyamatok",
"metricProcessesRunning": "futás",
"metricProcessesTotal": "teljes",
"metricUptime": "Üzemidő",
"metricsNotAvailable": "Mutatók nem állnak rendelkezésre",
"noHostSelected": "Nincs kiválasztva gazdagép",
"selectHost": "Válasszon egy hosztot..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Korong"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Mégsem",
"confirmRunButton": "Fut",
"confirmRunMessage": "Futtassa a \"{{name}} \" programot?"
}
}
},
"terminal": {
"connected": "Csatlakoztatva"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Belum ada tindakan cepat.",
"buttonLabel": "Label tombol",
"selectSnippetPlaceholder": "Pilih cuplikan...",
"addActionBtn": "Tambahkan Tindakan"
"addActionBtn": "Tambahkan Tindakan",
"hostNotFound": "Host tidak ditemukan",
"loadingHost": "Memuat host..."
},
"onboarding": {
"feature_metrics": "Metrik dan peringatan",
@@ -255,6 +257,41 @@
"noData": "Tidak ada data yang tersedia",
"showCurrentValue": "Tampilkan Nilai Saat Ini",
"chartMetric": "Metrik",
"metricRange": "Jangkauan"
"metricRange": "Jangkauan",
"host": "Tuan rumah",
"loading": "Memuat...",
"metricCpu": "CPU",
"metricDisk": "Disk",
"metricHostname": "Nama host",
"metricKernel": "Inti",
"metricMemory": "Ingatan",
"metricNetwork": "Jaringan",
"metricOs": "Sistem Operasi",
"metricProcesses": "Proses",
"metricProcessesRunning": "berlari",
"metricProcessesTotal": "total",
"metricUptime": "Waktu aktif",
"metricsNotAvailable": "Metrik tidak tersedia",
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
"selectHost": "Pilih tuan rumah..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Membatalkan",
"confirmRunButton": "Berlari",
"confirmRunMessage": "Jalankan \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Terhubung"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Nessuna azione rapida ancora.",
"buttonLabel": "Etichetta pulsante",
"selectSnippetPlaceholder": "Seleziona snippet...",
"addActionBtn": "Aggiungi Azione"
"addActionBtn": "Aggiungi Azione",
"hostNotFound": "Host non trovato",
"loadingHost": "Caricamento host..."
},
"onboarding": {
"feature_metrics": "Metriche e avvisi",
@@ -255,6 +257,41 @@
"noData": "Nessun dato disponibile",
"showCurrentValue": "Mostra valore corrente",
"chartMetric": "Metrica",
"metricRange": "Intervallo"
"metricRange": "Intervallo",
"host": "Host",
"loading": "Caricamento...",
"metricCpu": "CPU",
"metricDisk": "Disco",
"metricHostname": "Nome host",
"metricKernel": "Kernel",
"metricMemory": "Memoria",
"metricNetwork": "Rete",
"metricOs": "OS",
"metricProcesses": "Processi",
"metricProcessesRunning": "in esecuzione",
"metricProcessesTotal": "totale",
"metricUptime": "Uptime",
"metricsNotAvailable": "Metriche non disponibili",
"noHostSelected": "Nessun host selezionato",
"selectHost": "Seleziona un host..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disco"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Annulla",
"confirmRunButton": "Esegui",
"confirmRunMessage": "Eseguire \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Connesso"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "クイックアクションはまだありません",
"buttonLabel": "ボタンラベル",
"selectSnippetPlaceholder": "スニペットを選択...",
"addActionBtn": "アクションを追加"
"addActionBtn": "アクションを追加",
"hostNotFound": "ホストが見つかりません",
"loadingHost": "ホストを読み込み中..."
},
"onboarding": {
"feature_metrics": "指標とアラート",
@@ -255,6 +257,41 @@
"noData": "利用可能なデータはありません",
"showCurrentValue": "現在の値を表示",
"chartMetric": "メトリック",
"metricRange": "範囲"
"metricRange": "範囲",
"host": "ホスト",
"loading": "読み込み中...",
"metricCpu": "CPU",
"metricDisk": "ディスク",
"metricHostname": "ホスト名",
"metricKernel": "カーネル",
"metricMemory": "メモリ",
"metricNetwork": "ネットワーク",
"metricOs": "OS",
"metricProcesses": "プロセス",
"metricProcessesRunning": "実行中",
"metricProcessesTotal": "合計",
"metricUptime": "稼働時間",
"metricsNotAvailable": "メトリクスは利用できません",
"noHostSelected": "ホストが選択されていません",
"selectHost": "ホストを選択..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "ディスク"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "キャンセル",
"confirmRunButton": "実行",
"confirmRunMessage": "「{{name}}」を実行しますか?"
}
}
},
"terminal": {
"connected": "接続中"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "아직 빠른 작업이 없습니다.",
"buttonLabel": "버튼 레이블",
"selectSnippetPlaceholder": "스니펫 선택...",
"addActionBtn": "작업 추가"
"addActionBtn": "작업 추가",
"hostNotFound": "호스트를 찾을 수 없습니다",
"loadingHost": "호스트 로드 중..."
},
"onboarding": {
"feature_metrics": "지표 및 알림",
@@ -255,6 +257,41 @@
"noData": "데이터 없음",
"showCurrentValue": "현재 값 표시",
"chartMetric": "메트릭",
"metricRange": "범위"
"metricRange": "범위",
"host": "호스트",
"loading": "로딩 중...",
"metricCpu": "CPU",
"metricDisk": "디스크",
"metricHostname": "호스트명",
"metricKernel": "커널",
"metricMemory": "메모리",
"metricNetwork": "네트워크",
"metricOs": "OS",
"metricProcesses": "프로세스",
"metricProcessesRunning": "실행 중",
"metricProcessesTotal": "전체",
"metricUptime": "업타임",
"metricsNotAvailable": "메트릭을 사용할 수 없습니다",
"noHostSelected": "선택된 호스트 없음",
"selectHost": "호스트 선택..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "디스크"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "취소",
"confirmRunButton": "실행",
"confirmRunMessage": "\"{{name}}\"을(를) 실행하시겠습니까?"
}
}
},
"terminal": {
"connected": "연결됨"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Nog geen snelle acties ondernomen.",
"buttonLabel": "Knoplabel",
"selectSnippetPlaceholder": "Selecteer fragment...",
"addActionBtn": "Actie toevoegen"
"addActionBtn": "Actie toevoegen",
"hostNotFound": "Host niet gevonden",
"loadingHost": "Host wordt geladen..."
},
"onboarding": {
"feature_metrics": "Statistieken en waarschuwingen",
@@ -255,6 +257,41 @@
"noData": "Geen gegevens beschikbaar",
"showCurrentValue": "Toon huidige waarde",
"chartMetric": "Metrisch",
"metricRange": "Bereik"
"metricRange": "Bereik",
"host": "Gastheer",
"loading": "Laden...",
"metricCpu": "CPU",
"metricDisk": "Schijf",
"metricHostname": "Hostnaam",
"metricKernel": "Kernel",
"metricMemory": "Geheugen",
"metricNetwork": "Netwerk",
"metricOs": "OS",
"metricProcesses": "Processen",
"metricProcessesRunning": "rennen",
"metricProcessesTotal": "totaal",
"metricUptime": "Beschikbaarheid",
"metricsNotAvailable": "Gegevens niet beschikbaar",
"noHostSelected": "Geen host geselecteerd",
"selectHost": "Selecteer een host..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Schijf"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Annuleren",
"confirmRunButton": "Loop",
"confirmRunMessage": "Run \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Aangesloten"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Ingen hurtighandlinger ennå.",
"buttonLabel": "Knappetikett",
"selectSnippetPlaceholder": "Velg utdrag...",
"addActionBtn": "Legg til handling"
"addActionBtn": "Legg til handling",
"hostNotFound": "Verten ble ikke funnet",
"loadingHost": "Laster inn vert ..."
},
"onboarding": {
"feature_metrics": "Målinger og varsler",
@@ -255,6 +257,41 @@
"noData": "Ingen data tilgjengelig",
"showCurrentValue": "Vis gjeldende verdi",
"chartMetric": "Metrisk",
"metricRange": "Spekter"
"metricRange": "Spekter",
"host": "Vert",
"loading": "Laster inn...",
"metricCpu": "CPU",
"metricDisk": "Disk",
"metricHostname": "Vertsnavn",
"metricKernel": "Kjerne",
"metricMemory": "Hukommelse",
"metricNetwork": "Nettverk",
"metricOs": "OS",
"metricProcesses": "Prosesser",
"metricProcessesRunning": "løping",
"metricProcessesTotal": "total",
"metricUptime": "Oppetid",
"metricsNotAvailable": "Målinger er ikke tilgjengelige",
"noHostSelected": "Ingen vert valgt",
"selectHost": "Velg en vert..."
},
"dashboard": {
"cpu": "CPU",
"ram": "VÆR"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Kansellere",
"confirmRunButton": "Løp",
"confirmRunMessage": "Kjør \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Tilkoblet"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Brak szybkich akcji.",
"buttonLabel": "Etykieta przycisku",
"selectSnippetPlaceholder": "Wybierz snippet...",
"addActionBtn": "Dodaj akcję"
"addActionBtn": "Dodaj akcję",
"hostNotFound": "Nie znaleziono hosta",
"loadingHost": "Ładowanie hosta..."
},
"onboarding": {
"feature_metrics": "Metryki i alerty",
@@ -255,6 +257,41 @@
"noData": "Brak dostępnych danych",
"showCurrentValue": "Pokaż wartość bieżącą",
"chartMetric": "Metryka",
"metricRange": "Zakres"
"metricRange": "Zakres",
"host": "Host",
"loading": "Ładowanie...",
"metricCpu": "Procesor",
"metricDisk": "Dysk",
"metricHostname": "Nazwa hosta",
"metricKernel": "Jądro",
"metricMemory": "Pamięć",
"metricNetwork": "Sieć",
"metricOs": "System operacyjny",
"metricProcesses": "Procesy",
"metricProcessesRunning": "działa",
"metricProcessesTotal": "łącznie",
"metricUptime": "Czas działania",
"metricsNotAvailable": "Metryki niedostępne",
"noHostSelected": "Nie wybrano hosta",
"selectHost": "Wybierz hosta..."
},
"dashboard": {
"cpu": "Procesor",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Dysk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Anuluj",
"confirmRunButton": "Uruchom",
"confirmRunMessage": "Uruchomić „{{name}}”?"
}
}
},
"terminal": {
"connected": "Połączono"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Nenhuma ação imediata ainda.",
"buttonLabel": "Rótulo do botão",
"selectSnippetPlaceholder": "Selecione o trecho...",
"addActionBtn": "Adicionar ação"
"addActionBtn": "Adicionar ação",
"hostNotFound": "Host não encontrado",
"loadingHost": "Carregando host..."
},
"onboarding": {
"feature_metrics": "Métricas e alertas",
@@ -255,6 +257,41 @@
"noData": "Não há dados disponíveis.",
"showCurrentValue": "Mostrar valor atual",
"chartMetric": "Métrica",
"metricRange": "Faixa"
"metricRange": "Faixa",
"host": "Hospedar",
"loading": "Carregando...",
"metricCpu": "CPU",
"metricDisk": "Disco",
"metricHostname": "Nome do host",
"metricKernel": "Kernel",
"metricMemory": "Memória",
"metricNetwork": "Rede",
"metricOs": "SO",
"metricProcesses": "Processos",
"metricProcessesRunning": "correndo",
"metricProcessesTotal": "total",
"metricUptime": "Tempo de atividade",
"metricsNotAvailable": "Métricas não disponíveis",
"noHostSelected": "Nenhum host selecionado",
"selectHost": "Selecione um host..."
},
"dashboard": {
"cpu": "CPU",
"ram": "BATER"
},
"dashboardTab": {
"disk": "Disco"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Cancelar",
"confirmRunButton": "Correr",
"confirmRunMessage": "Executar \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Conectado"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Ainda não existem ações rápidas.",
"buttonLabel": "Texto do botão",
"selectSnippetPlaceholder": "Selecionar snippet...",
"addActionBtn": "Adicionar Ação"
"addActionBtn": "Adicionar Ação",
"hostNotFound": "Host não encontrado",
"loadingHost": "A carregar o host..."
},
"onboarding": {
"feature_metrics": "Métricas e alertas",
@@ -255,6 +257,41 @@
"noData": "Sem dados disponíveis",
"showCurrentValue": "Mostrar valor atual",
"chartMetric": "Métrica",
"metricRange": "Intervalo"
"metricRange": "Intervalo",
"host": "Host",
"loading": "A carregar...",
"metricCpu": "CPU",
"metricDisk": "Disco",
"metricHostname": "Nome do host",
"metricKernel": "Kernel",
"metricMemory": "Memória",
"metricNetwork": "Rede",
"metricOs": "SO",
"metricProcesses": "Processos",
"metricProcessesRunning": "em execução",
"metricProcessesTotal": "total",
"metricUptime": "Tempo de atividade",
"metricsNotAvailable": "Métricas não disponíveis",
"noHostSelected": "Nenhum anfitrião selecionado",
"selectHost": "Selecionar um host..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disco"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Cancelar",
"confirmRunButton": "Executar",
"confirmRunMessage": "Executar \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Ligado"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Nicio acțiune rapidă încă.",
"buttonLabel": "Etichetă buton",
"selectSnippetPlaceholder": "Selectați fragmentul...",
"addActionBtn": "Adăugați o acțiune"
"addActionBtn": "Adăugați o acțiune",
"hostNotFound": "Gazdă negăsită",
"loadingHost": "Se încarcă gazda..."
},
"onboarding": {
"feature_metrics": "Metrici și alerte",
@@ -255,6 +257,41 @@
"noData": "Nu sunt disponibile date",
"showCurrentValue": "Afișează valoarea curentă",
"chartMetric": "Metric",
"metricRange": "Gamă"
"metricRange": "Gamă",
"host": "Gazdă",
"loading": "Încărcare...",
"metricCpu": "CPU",
"metricDisk": "Disc",
"metricHostname": "Nume gazdă",
"metricKernel": "Nucleu",
"metricMemory": "Memorie",
"metricNetwork": "Reţea",
"metricOs": "Sistem de operare",
"metricProcesses": "Procese",
"metricProcessesRunning": "funcţionare",
"metricProcessesTotal": "total",
"metricUptime": "Timp de funcționare",
"metricsNotAvailable": "Indicatorii nu sunt disponibili",
"noHostSelected": "Nicio gazdă selectată",
"selectHost": "Selectați o gazdă..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disc"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Anula",
"confirmRunButton": "Aleargă",
"confirmRunMessage": "Executați „{{name}}”?"
}
}
},
"terminal": {
"connected": "Conectat"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Пока нет быстрых действий.",
"buttonLabel": "Надпись кнопки",
"selectSnippetPlaceholder": "Выбрать сниппет...",
"addActionBtn": "Добавить действие"
"addActionBtn": "Добавить действие",
"hostNotFound": "Хост не найден",
"loadingHost": "Загрузка хоста..."
},
"onboarding": {
"feature_metrics": "Метрики и оповещения",
@@ -255,6 +257,41 @@
"noData": "Нет данных",
"showCurrentValue": "Показать текущее значение",
"chartMetric": "Метрика",
"metricRange": "Диапазон"
"metricRange": "Диапазон",
"host": "Хост",
"loading": "Загрузка...",
"metricCpu": "ЦП",
"metricDisk": "Диск",
"metricHostname": "Имя хоста",
"metricKernel": "Ядро",
"metricMemory": "Память",
"metricNetwork": "Сеть",
"metricOs": "ОС",
"metricProcesses": "Процессы",
"metricProcessesRunning": "запущено",
"metricProcessesTotal": "всего",
"metricUptime": "Время работы",
"metricsNotAvailable": "Метрики недоступны",
"noHostSelected": "Хост не выбран",
"selectHost": "Выберите хост..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Диск"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Отмена",
"confirmRunButton": "Запустить",
"confirmRunMessage": "Запустить \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Подключено"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Још нема брзих акција.",
"buttonLabel": "Ознака дугмета",
"selectSnippetPlaceholder": "Изаберите исечак...",
"addActionBtn": "Додај акцију"
"addActionBtn": "Додај акцију",
"hostNotFound": "Хост није пронађен",
"loadingHost": "Учитавање хоста..."
},
"onboarding": {
"feature_metrics": "Метрике и упозорења",
@@ -255,6 +257,41 @@
"noData": "Нема доступних података",
"showCurrentValue": "Прикажи тренутну вредност",
"chartMetric": "Метрика",
"metricRange": "Домет"
"metricRange": "Домет",
"host": "Домаћин",
"loading": "Учитавање...",
"metricCpu": "Процесор",
"metricDisk": "Диск",
"metricHostname": "Име хоста",
"metricKernel": "Језгро",
"metricMemory": "Памћење",
"metricNetwork": "Мрежа",
"metricOs": "ОС",
"metricProcesses": "Процеси",
"metricProcessesRunning": "трчање",
"metricProcessesTotal": "укупно",
"metricUptime": "Време непрекидног рада",
"metricsNotAvailable": "Метрике нису доступне",
"noHostSelected": "Није изабран домаћин",
"selectHost": "Изаберите хост..."
},
"dashboard": {
"cpu": "Процесор",
"ram": "РАМ меморија"
},
"dashboardTab": {
"disk": "Диск"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Откажи",
"confirmRunButton": "Трчи",
"confirmRunMessage": "Покрени „{{name}}“?"
}
}
},
"terminal": {
"connected": "Повезано"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Inga snabba åtgärder ännu.",
"buttonLabel": "Knappetikett",
"selectSnippetPlaceholder": "Välj utdrag...",
"addActionBtn": "Lägg till åtgärd"
"addActionBtn": "Lägg till åtgärd",
"hostNotFound": "Värden hittades inte",
"loadingHost": "Laddar värd..."
},
"onboarding": {
"feature_metrics": "Mätvärden och varningar",
@@ -255,6 +257,41 @@
"noData": "Inga data tillgängliga",
"showCurrentValue": "Visa aktuellt värde",
"chartMetric": "Metrisk",
"metricRange": "Räckvidd"
"metricRange": "Räckvidd",
"host": "Värd",
"loading": "Belastning...",
"metricCpu": "CPU",
"metricDisk": "Disk",
"metricHostname": "Värdnamn",
"metricKernel": "Kärna",
"metricMemory": "Minne",
"metricNetwork": "Nätverk",
"metricOs": "operativsystem",
"metricProcesses": "Processer",
"metricProcessesRunning": "spring",
"metricProcessesTotal": "total",
"metricUptime": "Drifttid",
"metricsNotAvailable": "Mätvärden är inte tillgängliga",
"noHostSelected": "Ingen värd vald",
"selectHost": "Välj en värd..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM-minne"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Avboka",
"confirmRunButton": "Sikt",
"confirmRunMessage": "Kör \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Ansluten"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "ยังไม่มีการดำเนินการใดๆ อย่างเร่งด่วนในขณะนี้",
"buttonLabel": "ป้ายกำกับปุ่ม",
"selectSnippetPlaceholder": "เลือกส่วนย่อย...",
"addActionBtn": "เพิ่มการดำเนินการ"
"addActionBtn": "เพิ่มการดำเนินการ",
"hostNotFound": "ไม่พบโฮสต์",
"loadingHost": "กำลังโหลดโฮสต์..."
},
"onboarding": {
"feature_metrics": "ตัวชี้วัดและการแจ้งเตือน",
@@ -255,6 +257,41 @@
"noData": "ไม่มีข้อมูล",
"showCurrentValue": "แสดงมูลค่าปัจจุบัน",
"chartMetric": "เมตริก",
"metricRange": "พิสัย"
"metricRange": "พิสัย",
"host": "เจ้าภาพ",
"loading": "กำลังโหลด...",
"metricCpu": "ซีพียู",
"metricDisk": "ดิสก์",
"metricHostname": "ชื่อโฮสต์",
"metricKernel": "เคอร์เนล",
"metricMemory": "หน่วยความจำ",
"metricNetwork": "เครือข่าย",
"metricOs": "โอเอส",
"metricProcesses": "กระบวนการ",
"metricProcessesRunning": "วิ่ง",
"metricProcessesTotal": "ทั้งหมด",
"metricUptime": "เวลาใช้งาน",
"metricsNotAvailable": "ข้อมูลตัวชี้วัดไม่พร้อมใช้งาน",
"noHostSelected": "ไม่ได้เลือกโฮสต์",
"selectHost": "เลือกโฮสต์..."
},
"dashboard": {
"cpu": "ซีพียู",
"ram": "แรม"
},
"dashboardTab": {
"disk": "ดิสก์"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "ยกเลิก",
"confirmRunButton": "วิ่ง",
"confirmRunMessage": "เรียกใช้ \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "เชื่อมต่อแล้ว"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Henüz hızlı eylem yok.",
"buttonLabel": "Düğme etiketi",
"selectSnippetPlaceholder": "Parçacık seç...",
"addActionBtn": "Eylem Ekle"
"addActionBtn": "Eylem Ekle",
"hostNotFound": "Host bulunamadı",
"loadingHost": "Host yükleniyor..."
},
"onboarding": {
"feature_metrics": "Ölçümler ve uyarılar",
@@ -255,6 +257,41 @@
"noData": "Veri yok",
"showCurrentValue": "Geçerli Değeri Göster",
"chartMetric": "Metrik",
"metricRange": "Aralık"
"metricRange": "Aralık",
"host": "Sunucu",
"loading": "Yükleniyor...",
"metricCpu": "CPU",
"metricDisk": "Disk",
"metricHostname": "Ana Bilgisayar Adı",
"metricKernel": "Çekirdek",
"metricMemory": "Bellek",
"metricNetwork": "Ağ",
"metricOs": "İşletim Sistemi",
"metricProcesses": "İşlemler",
"metricProcessesRunning": "çalışan",
"metricProcessesTotal": "toplam",
"metricUptime": "Çalışma Süresi",
"metricsNotAvailable": "Metrikler kullanılamıyor",
"noHostSelected": "Sunucu seçilmedi",
"selectHost": "Bir sunucu seçin..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Disk"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "İptal",
"confirmRunButton": "Çalıştır",
"confirmRunMessage": "\"{{name}}\" çalıştırılsın mı?"
}
}
},
"terminal": {
"connected": "Bağlandı"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Поки що жодних швидких дій.",
"buttonLabel": "Мітка кнопки",
"selectSnippetPlaceholder": "Вибрати фрагмент...",
"addActionBtn": "Додати дію"
"addActionBtn": "Додати дію",
"hostNotFound": "Хост не знайдено",
"loadingHost": "Завантаження хоста..."
},
"onboarding": {
"feature_metrics": "Метрики та сповіщення",
@@ -255,6 +257,41 @@
"noData": "Дані недоступні",
"showCurrentValue": "Показати поточне значення",
"chartMetric": "Метрика",
"metricRange": "Діапазон"
"metricRange": "Діапазон",
"host": "Хост",
"loading": "Завантаження...",
"metricCpu": "Процесор",
"metricDisk": "Диск",
"metricHostname": "Ім'я хоста",
"metricKernel": "Ядро",
"metricMemory": "Пам'ять",
"metricNetwork": "Мережа",
"metricOs": "ОС",
"metricProcesses": "Процеси",
"metricProcessesRunning": "біг",
"metricProcessesTotal": "загальна сума",
"metricUptime": "Час безперебійної роботи",
"metricsNotAvailable": "Метрики недоступні",
"noHostSelected": "Хост не вибрано",
"selectHost": "Виберіть хоста..."
},
"dashboard": {
"cpu": "Процесор",
"ram": "Оперативна пам'ять"
},
"dashboardTab": {
"disk": "Диск"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Скасувати",
"confirmRunButton": "Бігти",
"confirmRunMessage": "Запустити \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Підключено"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "Chưa có hành động nhanh nào.",
"buttonLabel": "Nhãn nút",
"selectSnippetPlaceholder": "Chọn đoạn mã...",
"addActionBtn": "Thêm hành động"
"addActionBtn": "Thêm hành động",
"hostNotFound": "Không tìm thấy máy chủ",
"loadingHost": "Đang tải máy chủ..."
},
"onboarding": {
"feature_metrics": "Số liệu và cảnh báo",
@@ -255,6 +257,41 @@
"noData": "Không có dữ liệu",
"showCurrentValue": "Hiển thị giá trị hiện tại",
"chartMetric": "Chỉ số",
"metricRange": "Phạm vi"
"metricRange": "Phạm vi",
"host": "Máy chủ",
"loading": "Đang tải...",
"metricCpu": "CPU",
"metricDisk": "Đĩa",
"metricHostname": "Tên máy chủ",
"metricKernel": "Nhân",
"metricMemory": "Bộ nhớ",
"metricNetwork": "Mạng",
"metricOs": "Hệ điều hành",
"metricProcesses": "Tiến trình",
"metricProcessesRunning": "đang chạy",
"metricProcessesTotal": "tổng",
"metricUptime": "Thời gian hoạt động",
"metricsNotAvailable": "Chỉ số không khả dụng",
"noHostSelected": "Chưa chọn máy chủ",
"selectHost": "Chọn máy chủ..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "Ổ đĩa"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "Hủy",
"confirmRunButton": "Chạy",
"confirmRunMessage": "Chạy \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "Đã kết nối"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "尚无快速操作。",
"buttonLabel": "按钮标签",
"selectSnippetPlaceholder": "选择代码片段...",
"addActionBtn": "添加操作"
"addActionBtn": "添加操作",
"hostNotFound": "未找到主机",
"loadingHost": "正在加载主机..."
},
"onboarding": {
"feature_metrics": "指标与告警",
@@ -255,6 +257,41 @@
"noData": "暂无数据",
"showCurrentValue": "显示当前值",
"chartMetric": "指标",
"metricRange": "范围"
"metricRange": "范围",
"host": "主机",
"loading": "加载中...",
"metricCpu": "CPU",
"metricDisk": "磁盘",
"metricHostname": "主机名",
"metricKernel": "内核",
"metricMemory": "内存",
"metricNetwork": "网络",
"metricOs": "操作系统",
"metricProcesses": "进程",
"metricProcessesRunning": "运行中",
"metricProcessesTotal": "总数",
"metricUptime": "运行时间",
"metricsNotAvailable": "指标不可用",
"noHostSelected": "未选择主机",
"selectHost": "选择主机..."
},
"dashboard": {
"cpu": "CPU",
"ram": "内存"
},
"dashboardTab": {
"disk": "磁盘"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "取消",
"confirmRunButton": "运行",
"confirmRunMessage": "运行“{{name}}”?"
}
}
},
"terminal": {
"connected": "已连接"
}
}
@@ -240,7 +240,9 @@
"noQuickActions": "尚無快速動作。",
"buttonLabel": "按鈕標籤",
"selectSnippetPlaceholder": "選取程式碼片段...",
"addActionBtn": "新增動作"
"addActionBtn": "新增動作",
"hostNotFound": "找不到主機",
"loadingHost": "正在載入主機..."
},
"onboarding": {
"feature_metrics": "指標和警報",
@@ -255,6 +257,41 @@
"noData": "無可用資料",
"showCurrentValue": "顯示目前值",
"chartMetric": "指標",
"metricRange": "範圍"
"metricRange": "範圍",
"host": "主機",
"loading": "載入中...",
"metricCpu": "CPU",
"metricDisk": "磁碟",
"metricHostname": "主機名稱",
"metricKernel": "核心",
"metricMemory": "記憶體",
"metricNetwork": "網路",
"metricOs": "作業系統",
"metricProcesses": "程序",
"metricProcessesRunning": "執行中",
"metricProcessesTotal": "總數",
"metricUptime": "運作時間",
"metricsNotAvailable": "效能指標無法使用",
"noHostSelected": "未選取主機",
"selectHost": "選擇主機..."
},
"dashboard": {
"cpu": "CPU",
"ram": "RAM"
},
"dashboardTab": {
"disk": "磁碟"
},
"newUi": {
"sidebar": {
"snippets": {
"cancel": "取消",
"confirmRunButton": "執行",
"confirmRunMessage": "執行 \"{{name}}\"?"
}
}
},
"terminal": {
"connected": "已連線"
}
}
+96 -9
View File
@@ -2,7 +2,7 @@
"id": "host-metrics",
"name": "Host Metrics",
"version": "1.0.0",
"description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard, Tailscale).",
"description": "Live CPU, memory, disk, network and process metrics, plus host management (services, packages, firewall, cron, users, SSL, logs, WireGuard).",
"author": {
"name": "Termix"
},
@@ -20,12 +20,15 @@
"ssh:connect",
"db:own",
"network:serve",
"events:core",
"ui:surface"
],
"optionalDependencies": {
"automations": "^1.0.0"
},
"provides": [
{
"service": "host-metrics.viewers",
"version": "1.0.0",
"permission": "host-metrics.use"
}
],
"contributes": {
"tabs": [
{
@@ -35,14 +38,98 @@
"openFrom": ["host-context-menu", "palette"]
}
],
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["admin", "user"]
}
],
"settings": {
"admin": [
{
"key": "historyRetention",
"type": "custom",
"component": "retention"
"key": "metricsInterval",
"type": "number",
"labelKey": "settings.admin.metricsInterval.label",
"descriptionKey": "settings.admin.metricsInterval.description",
"default": 30,
"min": 5,
"max": 3600
},
{
"key": "historyRetentionDays",
"type": "number",
"labelKey": "settings.admin.historyRetentionDays.label",
"descriptionKey": "settings.admin.historyRetentionDays.description",
"default": 7,
"min": 1,
"max": 90
},
{
"key": "enabledForNewHosts",
"type": "boolean",
"labelKey": "settings.admin.enabledForNewHosts.label",
"descriptionKey": "settings.admin.enabledForNewHosts.description",
"default": true
}
]
],
"user": [
{
"key": "temperatureUnit",
"type": "select",
"labelKey": "settings.user.temperatureUnit.label",
"descriptionKey": "settings.user.temperatureUnit.description",
"default": "celsius",
"options": [
{
"value": "celsius",
"labelKey": "settings.user.temperatureUnit.celsius"
},
{
"value": "fahrenheit",
"labelKey": "settings.user.temperatureUnit.fahrenheit"
}
]
}
],
"host": {
"fields": [
{
"key": "metricsEnabled",
"type": "boolean",
"labelKey": "settings.host.metricsEnabled.label",
"default": true,
"hidden": true
},
{
"key": "metricsInterval",
"type": "number",
"labelKey": "settings.host.metricsInterval.label",
"min": 5,
"max": 3600,
"hidden": true
},
{
"key": "enabledWidgets",
"type": "json",
"labelKey": "settings.host.enabledWidgets.label",
"hidden": true
},
{
"key": "excludedMounts",
"type": "json",
"labelKey": "settings.host.excludedMounts.label",
"hidden": true
},
{
"key": "monitoredMounts",
"type": "json",
"labelKey": "settings.host.monitoredMounts.label",
"hidden": true
}
]
}
},
"actionSlots": [
{
@@ -0,0 +1,51 @@
-- host-metrics 0001: adopt_host_metrics_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `host_metrics_preferences` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`layout` text NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `host_metrics_preferences` TO `p_host_metrics_host_metrics_preferences`;
CREATE TABLE IF NOT EXISTS `host_health_checks` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`checks` text NOT NULL,
`interval_seconds` int NOT NULL DEFAULT 300,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `host_health_checks` TO `p_host_metrics_host_health_checks`;
CREATE TABLE IF NOT EXISTS `host_health_history` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`check_id` varchar(255) NOT NULL,
`ts` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`ok` boolean NOT NULL,
`latency_ms` int,
`detail` text,
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `host_health_history` TO `p_host_metrics_host_health_history`;
CREATE TABLE IF NOT EXISTS `host_metrics_history` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`host_id` int NOT NULL,
`ts` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`cpu_percent` double,
`mem_percent` double,
`disk_percent` double,
`net_rx_bytes` int,
`net_tx_bytes` int,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `host_metrics_history` TO `p_host_metrics_host_metrics_history`;
@@ -0,0 +1,55 @@
-- host-metrics 0001: adopt_host_metrics_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "host_metrics_preferences" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"layout" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" ("user_id", "host_id");
ALTER TABLE "host_metrics_preferences" RENAME TO "p_host_metrics_host_metrics_preferences";
CREATE TABLE IF NOT EXISTS "host_health_checks" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"checks" text NOT NULL,
"interval_seconds" integer NOT NULL DEFAULT 300,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_health_checks_user_host" ON "host_health_checks" ("user_id", "host_id");
ALTER TABLE "host_health_checks" RENAME TO "p_host_metrics_host_health_checks";
CREATE TABLE IF NOT EXISTS "host_health_history" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"check_id" varchar(255) NOT NULL,
"ts" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ok" boolean NOT NULL,
"latency_ms" integer,
"detail" text,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_host_health_history_lookup" ON "host_health_history" ("user_id", "host_id", "check_id", "ts");
ALTER TABLE "host_health_history" RENAME TO "p_host_metrics_host_health_history";
CREATE TABLE IF NOT EXISTS "host_metrics_history" (
"id" serial PRIMARY KEY,
"host_id" integer NOT NULL,
"ts" varchar(255) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cpu_percent" double precision,
"mem_percent" double precision,
"disk_percent" double precision,
"net_rx_bytes" integer,
"net_tx_bytes" integer,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_host_metrics_history_host_ts" ON "host_metrics_history" ("host_id", "ts");
ALTER TABLE "host_metrics_history" RENAME TO "p_host_metrics_host_metrics_history";
@@ -0,0 +1,175 @@
{
"version": 1,
"tables": {
"host_metrics_preferences": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"layout": {
"type": "text",
"notNull": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_host_metrics_prefs_user_host",
"columns": ["userId", "hostId"],
"unique": true
}
],
"adopts": "host_metrics_preferences"
},
"host_health_checks": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"checks": {
"type": "text",
"notNull": true
},
"intervalSeconds": {
"type": "integer",
"notNull": true,
"defaultValue": 300
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_host_health_checks_user_host",
"columns": ["userId", "hostId"],
"unique": true
}
],
"adopts": "host_health_checks"
},
"host_health_history": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"checkId": {
"type": "varchar",
"length": 255,
"notNull": true
},
"ts": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"ok": {
"type": "boolean",
"notNull": true
},
"latencyMs": {
"type": "integer"
},
"detail": {
"type": "text"
}
},
"indexes": [
{
"name": "idx_host_health_history_lookup",
"columns": ["userId", "hostId", "checkId", "ts"]
}
],
"adopts": "host_health_history"
},
"host_metrics_history": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"ts": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"cpuPercent": {
"type": "real"
},
"memPercent": {
"type": "real"
},
"diskPercent": {
"type": "real"
},
"netRxBytes": {
"type": "integer"
},
"netTxBytes": {
"type": "integer"
}
},
"indexes": [
{
"name": "idx_host_metrics_history_host_ts",
"columns": ["hostId", "ts"]
}
],
"adopts": "host_metrics_history"
}
}
}
@@ -0,0 +1,55 @@
-- host-metrics 0001: adopt_host_metrics_tables
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "host_metrics_preferences" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"layout" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_metrics_prefs_user_host" ON "host_metrics_preferences" ("user_id", "host_id");
ALTER TABLE "host_metrics_preferences" RENAME TO "p_host_metrics_host_metrics_preferences";
CREATE TABLE IF NOT EXISTS "host_health_checks" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"checks" text NOT NULL,
"interval_seconds" integer NOT NULL DEFAULT 300,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_host_health_checks_user_host" ON "host_health_checks" ("user_id", "host_id");
ALTER TABLE "host_health_checks" RENAME TO "p_host_metrics_host_health_checks";
CREATE TABLE IF NOT EXISTS "host_health_history" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"check_id" text NOT NULL,
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ok" integer NOT NULL,
"latency_ms" integer,
"detail" text,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_host_health_history_lookup" ON "host_health_history" ("user_id", "host_id", "check_id", "ts");
ALTER TABLE "host_health_history" RENAME TO "p_host_metrics_host_health_history";
CREATE TABLE IF NOT EXISTS "host_metrics_history" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"host_id" integer NOT NULL,
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cpu_percent" real,
"mem_percent" real,
"disk_percent" real,
"net_rx_bytes" integer,
"net_tx_bytes" integer,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_host_metrics_history_host_ts" ON "host_metrics_history" ("host_id", "ts");
ALTER TABLE "host_metrics_history" RENAME TO "p_host_metrics_host_metrics_history";
@@ -1,81 +0,0 @@
/**
* One-line hand-off from the metrics poller onto the ctx.events bus.
*
* The poller is already a very large module, so the hooks it calls live here
* instead. Everything is fire-and-forget: a failure in a subscriber must never
* disturb metric collection, and publishing onto the bus creates no static edge
* to the automations layer (which reads repositories the metrics module also
* pulls in).
*
* For the generic (non-metrics) internal event notifier, see
* `hosts/automation-events.ts` -- that one is shared across features.
*/
import {
pluginEvents,
TOPICS,
} from "../../../../src/backend/plugins/events.js";
import type { MetricsSnapshot } from "../../../automations/src/backend/conditions.js";
export interface HostMetricsPayload {
hostId: number;
ownerUserId: string;
metrics: MetricsSnapshot;
}
export interface HostStatusPayload {
hostId: number;
ownerUserId: string;
online: boolean;
}
export interface HostHealthCheckPayload {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export function notifyAutomationMetrics(
hostId: number,
ownerUserId: string,
metrics: MetricsSnapshot,
): void {
if (!ownerUserId) return;
pluginEvents.emit(TOPICS.hostMetrics, {
hostId,
ownerUserId,
metrics,
} satisfies HostMetricsPayload);
}
export function notifyAutomationStatus(
hostId: number,
ownerUserId: string,
online: boolean,
): void {
if (!ownerUserId) return;
pluginEvents.emit(TOPICS.hostStatus, {
hostId,
ownerUserId,
online,
} satisfies HostStatusPayload);
}
export function notifyAutomationHealthCheck(
hostId: number,
userId: string,
checkId: string,
ok: boolean,
detail?: string,
): void {
if (!userId) return;
pluginEvents.emit(TOPICS.hostHealthCheck, {
hostId,
userId,
checkId,
ok,
detail,
} satisfies HostHealthCheckPayload);
}
+196
View File
@@ -0,0 +1,196 @@
import type { Client } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import { detectHostPlatform } from "@termix/plugin-sdk/host-commands";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { collectDiskMetrics } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
import type { FirewallMetrics, PortsMetrics } from "../shared/stats-widgets.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
import type { MetricsState } from "./state.js";
import type { CollectorRegistry } from "./collectors.js";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
error instanceof Error &&
(error.message.includes("Host denied (verification failed)") ||
error.message.includes("Host key changed"))
);
}
export type CollectedMetrics = {
cpu: Awaited<ReturnType<typeof collectCpuMetrics>>;
memory: Awaited<ReturnType<typeof collectMemoryMetrics>>;
disk: Awaited<ReturnType<typeof collectDiskMetrics>>;
network: Awaited<ReturnType<typeof collectNetworkMetrics>>;
uptime: Awaited<ReturnType<typeof collectUptimeMetrics>>;
processes: Awaited<ReturnType<typeof collectProcessesMetrics>>;
system: Awaited<ReturnType<typeof collectSystemMetrics>>;
login_stats: Awaited<ReturnType<typeof collectLoginStats>>;
ports: PortsMetrics;
firewall: FirewallMetrics;
temperature: Awaited<ReturnType<typeof collectTemperatureMetrics>>;
gpu: GpuMetrics;
/** Results from other plugins' collectors, by collector id. */
extra?: Record<string, unknown>;
};
export interface CollectDeps {
ssh: PluginSsh;
state: MetricsState;
sessions: MetricsSessions;
collectors: CollectorRegistry;
}
async function optional<T>(collect: () => Promise<T>, fallback: T) {
try {
return await collect();
} catch {
return fallback;
}
}
/**
* One sample. Reuses a connection the person opened from the tab when there
* is one, otherwise borrows the "stats" pool, which answers password prompts
* only and fails fast on anything that needs a person.
*/
export async function collectMetrics(
deps: CollectDeps,
host: MetricsHost,
mounts: {
excludedMounts: string[];
monitoredMounts: Array<{ path: string; label?: string }>;
},
onAuthenticated?: () => void,
): Promise<CollectedMetrics> {
const { ssh, state, sessions, collectors } = deps;
if (!supportsMetrics(host, ssh)) {
throw new Error("Metrics collection only supported for SSH hosts");
}
if (state.authFailures.shouldSkip(host.id)) {
const reason = state.authFailures.getSkipReason(host.id);
throw new Error(reason || "Authentication failed");
}
const cached = state.metricsCache.get(host.id) as CollectedMetrics | null;
if (cached) {
onAuthenticated?.();
return cached;
}
return state.requestQueue.queueRequest(host.id, async () => {
const key = sessionKey(host.id, host.userId);
const existing = sessions.get(key);
try {
const collect = async (client: Client): Promise<CollectedMetrics> => {
onAuthenticated?.();
const platform = await detectHostPlatform(client);
const result: CollectedMetrics = {
cpu: await collectCpuMetrics(client, platform, host.id),
memory: await collectMemoryMetrics(client, platform),
disk: await collectDiskMetrics(
client,
mounts.excludedMounts,
mounts.monitoredMounts,
platform,
),
network: await collectNetworkMetrics(client, platform, host.id),
uptime: await collectUptimeMetrics(client, platform),
processes: await collectProcessesMetrics(client),
system: await collectSystemMetrics(client, platform),
login_stats: await optional(() => collectLoginStats(client), {
recentLogins: [],
failedLogins: [],
totalLogins: 0,
uniqueIPs: 0,
}),
ports: await optional(() => collectPortsMetrics(client), {
source: "none",
ports: [],
}),
firewall: await optional(() => collectFirewallMetrics(client), {
type: "none",
status: "unknown",
chains: [],
}),
temperature: await optional(() => collectTemperatureMetrics(client), {
source: "none",
highestCelsius: null,
sensors: [],
}),
gpu: await optional(() => collectGpuMetrics(client), {
source: "none",
gpus: [],
processes: [],
}),
};
const extra = await collectors.run(client, host.id, host.userId);
if (Object.keys(extra).length > 0) result.extra = extra;
state.metricsCache.set(host.id, result);
return result;
};
if (existing?.isConnected) {
existing.activeOperations++;
try {
const result = await collect(existing.client);
sessions.touch(key);
return result;
} finally {
existing.activeOperations--;
}
}
return await ssh.withConnection<CollectedMetrics, Client>(
host,
{
pool: "stats",
purpose: "metrics",
overrides: { readyTimeout: 60000 },
},
collect,
);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("TOTP authentication required")) {
throw error;
} else if (
error.message.includes("No password available") ||
error.message.includes("Unsupported authentication type") ||
error.message.includes("No SSH key available") ||
error.message.includes("Invalid SSH key format")
) {
state.authFailures.recordFailure(host.id, "AUTH", true);
} else if (isHostKeyVerificationError(error)) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
} else if (
error.message.includes("authentication") ||
error.message.includes("Permission denied") ||
error.message.includes("All configured authentication methods failed")
) {
state.authFailures.recordFailure(host.id, "AUTH");
} else if (
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT")
) {
state.authFailures.recordFailure(host.id, "TIMEOUT");
}
}
throw error;
}
});
}
@@ -0,0 +1,48 @@
import type { Client } from "ssh2";
import type { PluginServices } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
export const COLLECTORS_SERVICE = "host-metrics.collectors";
/**
* The "host-metrics.collectors" service, version 1. Another plugin provides
* it under its own provider name (manifest `provides[].names`) to add data
* to every sample. It runs on the sample's SSH connection, as the host's
* owner, and its result lands in the snapshot under `extra[<name>]`. A
* collector that throws is skipped for that sample.
*/
export interface MetricsCollectorV1 {
collect: (client: Client, hostId: number) => Promise<unknown>;
}
export class CollectorRegistry {
constructor(
private readonly services: PluginServices,
private readonly log: MetricsLogger,
) {}
async run(
client: Client,
hostId: number,
ownerUserId: string,
): Promise<Record<string, unknown>> {
const results: Record<string, unknown> = {};
for (const provider of this.services.providers(COLLECTORS_SERVICE)) {
if (!provider) continue;
try {
const collector = this.services.get<Partial<MetricsCollectorV1>>(
COLLECTORS_SERVICE,
{ provider, userId: ownerUserId },
);
if (typeof collector.collect !== "function") continue;
results[provider] = await collector.collect(client, hostId);
} catch (error) {
this.log.debug(`Metrics collector ${provider} failed`, {
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
return results;
}
}
+38 -47
View File
@@ -1,60 +1,51 @@
import type { Client } from "ssh2";
import { pluginSsh } from "./ssh.js";
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
export type StatsCapableHost = {
connectionType?: string;
authType?: string;
/** A host as ctx.ssh.resolveHost returns it, with the fields polling reads. */
export type MetricsHost = PluginSshHost & {
userId: string;
name?: string | null;
connectionType?: string | null;
enableSsh?: boolean | null;
password?: string | null;
sudoPassword?: string | null;
terminalConfig?: { sudoPassword?: string | null } | null;
enableDocker?: boolean | null;
jumpHosts?: Array<{ hostId: number }> | null;
};
export type TcpPingStatsConfig = {
statusCheckEnabled: boolean;
disableTcpPing?: boolean;
};
export function supportsMetrics(host: StatsCapableHost): boolean {
/** Metrics run over SSH, unattended, so the auth type has to allow that. */
export function supportsMetrics(
host: Pick<MetricsHost, "connectionType" | "enableSsh" | "authType">,
ssh: Pick<PluginSsh, "supportsBackground">,
): boolean {
const connectionType = host.connectionType || "ssh";
if (connectionType !== "ssh") return false;
return pluginSsh().supportsBackground(host.authType || "none");
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
return ssh.supportsBackground(host.authType || "none");
}
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
export function sudoPasswordOf(host: MetricsHost): string | undefined {
return host.sudoPassword || host.terminalConfig?.sudoPassword || undefined;
}
export function parseStatusHostIds(value: unknown): Set<number> | null {
if (value === undefined) return null;
if (typeof value !== "string") return new Set();
export type ConnectionLogType = "info" | "success" | "warning" | "error";
return new Set(
value
.split(",")
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0),
);
export interface ConnectionLog {
type: ConnectionLogType;
stage: string;
message: string;
details?: Record<string, unknown>;
}
export function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
/** The entry shape the connection log panel reads. */
export function connectionLog(
type: ConnectionLogType,
stage: string,
message: string,
details?: Record<string, unknown>,
): ConnectionLog {
return { type, stage, message, ...(details ? { details } : {}) };
}
const finish = (result: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
});
});
export function newSessionId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
@@ -1,131 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { createCurrentHostMetricsHistoryRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
type HistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
export function registerHostMetricsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
): void {
/**
* @openapi
* /metrics/history/{id}:
* get:
* summary: Get historical metrics for a host
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of metric history rows.
* 403:
* description: Access denied.
* 404:
* description: Host not found or no access.
*/
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
const hostId = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
// Normalize both sides to that format for reliable string comparison.
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await createCurrentHostMetricsHistoryRepository().listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({ rows, fromTs, toTs });
} catch (error) {
statsLogger.error("Failed to fetch metrics history", {
operation: "metrics_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch metrics history" });
}
});
}
@@ -0,0 +1,32 @@
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
/**
* Registered as ctx.registry.provide("host-metrics.hostImportNormalizer", ...)
* for core's bulk host import. A Termix export carries these settings under
* pluginSettings["host-metrics"]; exports from before 2.9.0 carried them in
* statsConfig, where the host's own interval only counted when
* useGlobalMetricsInterval was false.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const bag = raw.pluginSettings as
Record<string, Record<string, unknown>> | undefined;
const own = bag?.["host-metrics"];
if (own && typeof own === "object") {
const settings = readHostMetricsSettings(own);
return { ...settings };
}
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: null;
if (!legacy) return null;
const settings = readHostMetricsSettings({
...legacy,
metricsInterval:
legacy.useGlobalMetricsInterval === false ? legacy.metricsInterval : null,
});
return { ...settings };
}
+95 -21
View File
@@ -1,34 +1,108 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import {
onHostDeleted,
onHostLogin,
onHostUpdated,
shutdown,
startHostMetricsService,
} from "./routes.js";
import { createLogger } from "./log.js";
import { createHostMetricsRepository } from "./repository.js";
import { createMetricsState } from "./state.js";
import { MetricsSessions } from "./sessions.js";
import { CollectorRegistry } from "./collectors.js";
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
import { registerRoutes } from "./routes.js";
import { hostImportNormalizer } from "./host-import.js";
import { newSessionId, supportsMetrics } from "./helpers.js";
export type { MetricsCollectorV1 } from "./collectors.js";
export type { CollectionStatusPayload, SnapshotPayload } from "./poller.js";
/** The "host-metrics.viewers" service, version 1. Runs as the caller's user. */
export interface MetricsViewersV1 {
register: (
hostId: number,
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
heartbeat: (viewerSessionId: string) => boolean;
unregister: (hostId: number, viewerSessionId: string) => void;
}
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
await startHostMetricsService(ctx.http.router());
const log = createLogger(ctx.log);
const repository = await createHostMetricsRepository(ctx.db);
const state = createMetricsState();
const sessions = new MetricsSessions(ctx.schedule, log);
const collectors = new CollectorRegistry(ctx.services, log);
const poller = new MetricsPoller({
ctx,
log,
repository,
ssh: ctx.ssh,
state,
sessions,
collectors,
});
ctx.disposables.add(() => {
poller.dispose();
sessions.dispose();
});
registerRoutes(ctx.http.router<Router>(), {
ctx,
log,
poller,
sessions,
repository,
onHealthCheck: (event) => ctx.events.emit(TOPIC_HEALTH_CHECK, event),
});
ctx.schedule.every(60_000, () => poller.cleanupInactiveViewers());
ctx.schedule.every(10 * 60_000, () => {
state.authFailures.cleanup();
state.backoff.cleanup();
});
// Core used to POST these to this plugin's own port with an internal auth
// token. They are events now: no port, no shared secret, and the
// subscriptions are torn down with the plugin.
ctx.events.on("host.updated", (payload) => {
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
const { hostId } = payload as { hostId?: number };
if (hostId) void poller.hostUpdated(hostId);
});
ctx.events.on("host.deleted", (payload) => {
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostDeleted(hostId);
});
ctx.events.on("host.login", (payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
ctx.events.on("host.status", (payload) => {
const { hostId, status } = payload as { hostId?: number; status?: string };
if (hostId && status) poller.hostStatusChanged(hostId, status);
});
ctx.events.on("host.key.updated", (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) poller.hostKeyAccepted(hostId);
});
ctx.settings.onChange("metricsInterval", () => void poller.retimeAll());
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
register: async (hostId) => {
const userId = ctx.currentActor();
if (!userId) return { skipped: true, reason: "no_user" };
const host = await poller.resolve(hostId, userId);
if (!host) return { skipped: true, reason: "host_not_found" };
if (!supportsMetrics(host, ctx.ssh)) {
return { skipped: true, reason: "metrics_unsupported" };
}
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
return { skipped: true, reason: "metrics_disabled" };
}
const viewerSessionId = newSessionId("service");
poller.registerViewer(hostId, viewerSessionId, userId);
return { viewerSessionId };
},
heartbeat: (viewerSessionId) =>
poller.updateHeartbeat(viewerSessionId, ctx.currentActor()),
unregister: (hostId, viewerSessionId) =>
poller.unregisterViewer(hostId, viewerSessionId, ctx.currentActor()),
});
ctx.registry.provide(
"host-metrics.hostImportNormalizer",
hostImportNormalizer,
);
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
export async function deactivate() {
shutdown();
}
export async function deactivate() {}
@@ -0,0 +1,367 @@
import { Client, type ConnectConfig } from "ssh2";
import type { PluginSsh } from "@termix/plugin-sdk/backend";
import {
connectionLog,
newSessionId,
type ConnectionLog,
type MetricsHost,
} from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import { sessionKey, type MetricsSessions } from "./sessions.js";
const CONNECT_TIMEOUT_MS = 60_000;
const TOTP_TTL_MS = 180_000;
const MAX_TOTP_ATTEMPTS = 3;
export type StartResult =
| { status: 200; body: Record<string, unknown> }
| { status: 400 | 401 | 500; body: Record<string, unknown> };
export interface InteractiveDeps {
ssh: PluginSsh;
sessions: MetricsSessions;
log: MetricsLogger;
registerViewer: (hostId: number, sessionId: string, userId: string) => void;
}
function stageForError(message: string): [string, string] {
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
return ["dns", `DNS resolution failed: ${message}`];
}
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
return ["tcp", `TCP connection failed: ${message}`];
}
if (message.includes("handshake") || message.includes("key exchange")) {
return ["handshake", `SSH handshake failed: ${message}`];
}
if (/authentication/i.test(message)) {
return ["auth", `Authentication failed: ${message}`];
}
if (message.includes("verification failed")) {
return [
"handshake",
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
];
}
return ["error", `SSH connection failed: ${message}`];
}
/**
* Opens a connection from the tab, where a person can answer a TOTP prompt,
* and keeps it for polling. Resolves with the HTTP answer.
*/
export async function startInteractive(
deps: InteractiveDeps,
host: MetricsHost,
userId: string,
): Promise<StartResult> {
const { ssh, sessions, log } = deps;
const logs: ConnectionLog[] = [
connectionLog("info", "stats_connecting", "Starting metrics collection"),
connectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
connectionLog("info", "handshake", "Initiating SSH handshake"),
];
if (host.authType === "password") {
logs.push(connectionLog("info", "auth", "Authenticating with password"));
} else if (host.authType === "key") {
logs.push(connectionLog("info", "auth", "Authenticating with SSH key"));
}
const key = sessionKey(host.id, userId);
if (sessions.get(key)?.isConnected) {
logs.push(
connectionLog(
"success",
"stats_polling",
"Using existing metrics session",
),
);
return { status: 200, body: { success: true, connectionLogs: logs } };
}
const client = new Client();
const prepared = await ssh.prepare(host, {
purpose: "metrics",
client,
log: (level, message) => logs.push(connectionLog(level, "auth", message)),
});
const config = prepared.config as ConnectConfig;
config.readyTimeout = CONNECT_TIMEOUT_MS;
if (prepared.outcome.status !== "ready") {
const outcome = prepared.outcome;
logs.push(connectionLog("error", "auth", outcome.message));
if (outcome.status === "interaction-required") {
return {
status: 401,
body: {
error: outcome.message,
requiresAuthInteraction: outcome.interaction,
...(outcome.flag ? { [outcome.flag]: true } : {}),
connectionLogs: logs,
},
};
}
return {
status: 400,
body: { error: outcome.message, connectionLogs: logs },
};
}
try {
const result = await new Promise<Record<string, unknown>>(
(resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(
() =>
settle(() => {
client.end();
reject(new Error("Connection timeout"));
}),
CONNECT_TIMEOUT_MS,
);
client.on(
"keyboard-interactive",
(name, instructions, _lang, prompts, finish) => {
const decision = ssh.classifyKeyboardInteractive(
{ name, instructions, prompts },
host,
);
if (decision.kind !== "totp") {
finish(ssh.autoResponses(prompts, host.password));
return;
}
const sessionId = newSessionId("totp");
sessions.addPending({
client,
finish,
config,
createdAt: Date.now(),
sessionId,
hostId: host.id,
userId,
prompts: prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo ?? false,
})),
totpPromptIndex: decision.promptIndex,
resolvedPassword: host.password ?? undefined,
totpAttempts: 0,
});
logs.push(
connectionLog("info", "stats_totp", "TOTP verification required"),
);
settle(() =>
resolve({
success: false,
requires_totp: true,
sessionId,
prompt: prompts[decision.promptIndex].prompt,
}),
);
},
);
client.on("ready", () =>
settle(() => {
logs.push(
connectionLog(
"success",
"connected",
"SSH connection established successfully",
),
connectionLog(
"success",
"stats_polling",
"Metrics session established",
),
);
sessions.open(key, {
client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: host.id,
userId,
});
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(host.id, viewerSessionId, userId);
resolve({ success: true, viewerSessionId });
}),
);
client.on("error", (error) =>
settle(() => {
const message =
error instanceof Error ? error.message : String(error);
const [stage, text] = stageForError(message);
logs.push(connectionLog("error", stage, text));
log.error("SSH connection error in metrics/start", {
operation: "metrics_start_ssh_error",
hostId: host.id,
error: message,
});
reject(error);
}),
);
if (Array.isArray(host.jumpHosts) && host.jumpHosts.length > 0) {
logs.push(
connectionLog("info", "proxy", "Connecting via jump host chain"),
);
}
ssh
.openTransport(host, config as Record<string, unknown>)
.then((transport) => {
if (transport.jumpClient) {
const jumpClient = transport.jumpClient as Client;
client.on("close", () => jumpClient.end());
}
client.connect(config);
})
.catch((error) =>
settle(() => {
logs.push(
connectionLog(
"error",
"proxy",
`Connection setup failed: ${error instanceof Error ? error.message : String(error)}`,
),
);
reject(error);
}),
);
},
);
return { status: 200, body: { ...result, connectionLogs: logs } };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logs.push(
connectionLog(
"error",
"stats_connecting",
`Failed to start metrics: ${message}`,
),
);
return {
status: 500,
body: {
error: message || "Failed to start metrics collection",
connectionLogs: logs,
},
};
}
}
export type TotpResult = { status: number; body: Record<string, unknown> };
/** Sends the TOTP code a person typed and keeps the connection on success. */
export async function submitTotp(
deps: InteractiveDeps,
userId: string,
sessionId: string,
totpCode: string,
): Promise<TotpResult> {
const { sessions, log } = deps;
const session = sessions.getPending(sessionId);
if (!session) {
return {
status: 404,
body: { error: "TOTP session not found or expired" },
};
}
if (Date.now() - session.createdAt > TOTP_TTL_MS) {
sessions.dropPending(sessionId, true);
return { status: 408, body: { error: "TOTP session timeout" } };
}
if (session.userId !== userId) {
return { status: 403, body: { error: "Unauthorized" } };
}
session.totpAttempts++;
if (session.totpAttempts > MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
return { status: 429, body: { error: "Too many TOTP attempts" } };
}
const passwordFor = (prompt: string) =>
/password/i.test(prompt) && session.resolvedPassword
? session.resolvedPassword
: "";
try {
const responses = (session.prompts || []).map((p, index) =>
index === session.totpPromptIndex
? totpCode.trim()
: passwordFor(p.prompt),
);
const ready = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("TOTP verification timeout")),
30_000,
);
session.client.once(
"keyboard-interactive",
(_name, _instructions, _lang, prompts, finish) => {
log.warn("Second keyboard-interactive received after TOTP", {
operation: "totp_second_keyboard_interactive",
hostId: session.hostId,
});
finish(prompts.map((p) => passwordFor(p.prompt)));
},
);
session.client.once("ready", () => {
clearTimeout(timeout);
resolve();
});
session.client.once("error", (error) => {
clearTimeout(timeout);
reject(error);
});
});
session.finish(responses);
await ready;
sessions.open(sessionKey(session.hostId, userId), {
client: session.client,
isConnected: true,
lastActive: Date.now(),
activeOperations: 0,
hostId: session.hostId,
userId,
});
sessions.dropPending(sessionId);
const viewerSessionId = newSessionId("viewer");
deps.registerViewer(session.hostId, viewerSessionId, userId);
return { status: 200, body: { success: true, viewerSessionId } };
} catch (error) {
log.error("TOTP verification failed", {
operation: "totp_verification_failed",
hostId: session.hostId,
error: error instanceof Error ? error.message : String(error),
});
if (session.totpAttempts >= MAX_TOTP_ATTEMPTS) {
sessions.dropPending(sessionId, true);
}
return {
status: 401,
body: {
error: "TOTP verification failed",
attemptsRemaining: Math.max(
0,
MAX_TOTP_ATTEMPTS - session.totpAttempts,
),
},
};
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type Meta = Record<string, unknown>;
export interface MetricsLogger {
debug: (message: string, meta?: Meta) => void;
info: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
/** An error, or structured context, or both. */
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
return meta && Object.keys(meta).length > 0
? `${message} ${JSON.stringify(meta)}`
: message;
}
/** ctx.log with the structured context the old stats logger carried. */
export function createLogger(log: PluginLogger): MetricsLogger {
return {
debug: (message, meta) => log.debug(withMeta(message, meta)),
info: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, errorOrMeta, meta) => {
if (errorOrMeta instanceof Error) {
log.error(withMeta(message, meta), errorOrMeta);
return;
}
log.error(
withMeta(message, {
...((errorOrMeta as Meta | undefined) ?? {}),
...meta,
}),
);
},
};
}
@@ -1,7 +1,9 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
@@ -78,14 +80,12 @@ export function buildApplyCrontabCommand(body: string): string {
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
managerHandler(deps, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
@@ -95,7 +95,7 @@ export function registerCronRoutes(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"cron_replace",
async (client, _host, req) => {
@@ -1,15 +1,14 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
@@ -45,13 +44,14 @@ export function buildNftRuleCommand(
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
managerHandler(deps, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
@@ -60,7 +60,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_rule",
async (client, host, req) => {
@@ -105,7 +105,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_persist",
async (client, host) => {
@@ -1,13 +1,13 @@
import type { Express } from "express";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { isValidPort } from "./validation.js";
import type { Router } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
import type { HostMetricsRepository } from "../repository.js";
export interface HealthCheck {
id: string;
@@ -111,24 +111,12 @@ async function runChecks(
const HISTORY_KEEP = 500;
function recordHistory(
userId: string,
hostId: number,
results: HealthResult[],
): Promise<void> {
return createCurrentHostHealthRepository()
.recordHistory(userId, hostId, results, HISTORY_KEEP)
.then(() => undefined);
}
async function loadChecks(
repository: HostMetricsRepository,
userId: string,
hostId: number,
): Promise<HealthCheck[]> {
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
userId,
hostId,
);
const row = await repository.findChecks(userId, hostId);
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
@@ -139,60 +127,50 @@ async function loadChecks(
}
export function registerHealthRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, repository } = deps;
const record = async (
userId: string,
hostId: number,
results: HealthResult[],
) => {
if (!results.length) return;
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
for (const result of results) {
deps.onHealthCheck({
hostId,
userId,
checkId: result.checkId,
ok: result.ok,
detail: result.detail ?? undefined,
});
}
};
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
const history = (
await createCurrentHostHealthRepository().listHistory(
userId,
host.id,
200,
)
).map((row) => ({
checkId: row.checkId,
ts: row.ts,
ok: row.ok,
latencyMs: row.latencyMs,
detail: row.detail,
}));
return { checks, results, history };
},
),
managerHandler(deps, "connect", "health_get", async (client, host) => {
const userId = host.actorId;
const checks = await loadChecks(repository, userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
await record(userId, host.id, results);
const history = await repository.listHealth(userId, host.id, 200);
return { checks, results, history };
}),
);
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const userId = host.actorId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
@@ -206,13 +184,11 @@ export function registerHealthRoutes(
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
const now = new Date().toISOString();
await createCurrentHostHealthRepository().upsertChecks(
await repository.saveChecks(
userId,
host.id,
JSON.stringify(checks),
interval,
now,
);
return { success: true };
},
@@ -222,28 +198,11 @@ export function registerHealthRoutes(
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
return { results };
},
),
managerHandler(deps, "connect", "health_run", async (client, host) => {
const checks = await loadChecks(repository, host.actorId, host.id);
const results = await runChecks(client, checks);
await record(host.actorId, host.id, results);
return { results };
}),
);
}
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
@@ -19,10 +19,10 @@ import { registerWireGuardRoutes } from "./wireguard.js";
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Express,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, runOnHost } = deps;
const { validateHostId } = deps;
/**
* @openapi
@@ -44,7 +44,7 @@ export function registerManagerRoutes(
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
managerHandler(deps, "connect", "platform_detect", (client) =>
detectPlatform(client),
),
);
@@ -1,14 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "@termix/plugin-sdk/host-commands";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isAllowedPath,
isValidSystemdUnit,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
@@ -44,14 +41,12 @@ export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
managerHandler(deps, "connect", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
@@ -65,33 +60,28 @@ export function registerLogRoutes(
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
},
),
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
}),
);
}
@@ -1,25 +1,26 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import {
execCommand,
execElevated,
detectPlatform,
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
} from "@termix/plugin-sdk/host-commands";
import { isValidPackageName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
managerHandler(deps, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
@@ -35,7 +36,7 @@ export function registerPackageRoutes(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"packages_action",
async (client, host, req) => {
@@ -1,12 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPid,
isValidSignal,
type Signal,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
@@ -54,9 +49,10 @@ export function buildKillCommand(pid: number, signal: Signal): string {
}
export function registerProcessRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/processes/{id}:
@@ -74,7 +70,7 @@ export function registerProcessRoutes(
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
managerHandler(deps, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
@@ -109,7 +105,7 @@ export function registerProcessRoutes(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"processes_signal",
async (client, host, req) => {
@@ -1,11 +1,10 @@
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
import { ElevationError } from "@termix/plugin-sdk/host-commands";
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { statsLogger } from "../../../../../src/backend/utils/logger.js";
import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import { errorMessage } from "../util.js";
import type { MetricsLogger } from "../log.js";
import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -26,16 +25,15 @@ export class ManagerInputError extends Error {
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
runOnHost: RunOnHost,
level: HostAction,
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
level: PluginHostShareLevel,
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, userId, level, (client, host) =>
const result = await runOnHost(hostId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
@@ -49,13 +47,13 @@ export function managerHandler(
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
statsLogger.error(`Manager operation failed: ${operation}`, {
log.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: getErrorMessage(error, "Operation failed"),
error: errorMessage(error, "Operation failed"),
});
}
};
@@ -1,12 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
@@ -50,9 +49,10 @@ export function buildServiceActionCommand(
}
export function registerServiceRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/services/{id}:
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "connect", "services_list", async (client) => {
managerHandler(deps, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
@@ -105,7 +105,7 @@ export function registerServiceRoutes(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"services_action",
async (client, host, req) => {
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
@@ -106,13 +106,14 @@ export function parseDfMounts(output: string): MountUsage[] {
}
export function registerSimpleReadRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
managerHandler(deps, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
@@ -121,7 +122,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
managerHandler(deps, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
@@ -130,7 +131,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
+87 -104
View File
@@ -1,16 +1,16 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
detectPlatform,
} from "@termix/plugin-sdk/host-commands";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
@@ -149,14 +149,12 @@ export function buildRevokeCommand(client: AcmeClient, name: string): string {
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
@@ -188,78 +186,68 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
/**
@@ -290,31 +278,26 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
}
@@ -1,28 +1,43 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import type { HostMetricsRepository } from "../repository.js";
import type { MetricsLogger } from "../log.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
/** The user making the request, who may not own the host. */
actorId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* user has at least `level` access. Resolves the host (with sudoPassword) so
* managers can elevate. Rejects with an access error if not permitted.
* acting user has at least `level` access. Resolves the host (with
* sudoPassword) so managers can elevate. Rejects with an access error if not
* permitted.
*/
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: HostAction,
level: PluginHostShareLevel,
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface HealthCheckEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
log: MetricsLogger;
repository: HostMetricsRepository;
onHealthCheck: (event: HealthCheckEvent) => void;
}
@@ -1,11 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidUsername,
isValidGroupName,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
@@ -75,14 +71,12 @@ export function parseSudoers(output: string): string[] {
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "connect", "users_list", async (client) => {
managerHandler(deps, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
@@ -100,7 +94,7 @@ export function registerUserRoutes(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"users_action",
async (client, host, req) => {
@@ -0,0 +1,127 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
@@ -1,11 +1,11 @@
import type { Express } from "express";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface WireGuardPeer {
publicKey: string;
@@ -117,9 +117,10 @@ export function parseWireGuardData(output: string): WireGuardData {
}
export function registerWireGuardRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
@@ -140,23 +141,13 @@ export function registerWireGuardRoutes(
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
client,
PROBE_CMD,
host.sudoPassword,
{
forceSudo: false,
timeoutMs: 15000,
},
);
return parseWireGuardData(result.stdout);
},
),
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
forceSudo: false,
timeoutMs: 15000,
});
return parseWireGuardData(result.stdout);
}),
);
/**
@@ -192,7 +183,7 @@ export function registerWireGuardRoutes(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"wireguard_action",
async (client, host, req) => {
+469
View File
@@ -0,0 +1,469 @@
import type {
PluginContext,
PluginHostStatusEntry,
} from "@termix/plugin-sdk/backend";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
collectMetrics,
isHostKeyVerificationError,
type CollectDeps,
type CollectedMetrics,
} from "./collect.js";
import { supportsMetrics, type MetricsHost } from "./helpers.js";
import type { MetricsLogger } from "./log.js";
import type { HostMetricsRepository } from "./repository.js";
import type { MetricsViewer } from "./sessions.js";
import {
canStartInitialMetrics,
metricsConcurrencyFor,
type MetricsState,
} from "./state.js";
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
import { clearNetworkSampleCache } from "./widgets/network-collector.js";
export const DEFAULT_METRICS_INTERVAL = 30;
export const DEFAULT_RETENTION_DAYS = 7;
/** Viewers that stop sending heartbeats are dropped after this long. */
const VIEWER_TIMEOUT_MS = 120_000;
export const TOPIC_SNAPSHOT = "plugin.host-metrics.snapshot";
export const TOPIC_STATUS = "plugin.host-metrics.status";
export const TOPIC_HEALTH_CHECK = "plugin.host-metrics.health-check";
/** Where collection stands for a host, sent when it changes. */
export type CollectionState =
"collecting" | "auth_failed" | "host_key_changed" | "unreachable";
export interface SnapshotPayload {
hostId: number;
ownerUserId: string;
metrics: CollectedMetrics;
}
export interface CollectionStatusPayload {
hostId: number;
ownerUserId: string;
state: CollectionState;
previous: CollectionState | null;
}
interface Polled {
host: MetricsHost;
viewerUserId: string;
stop: () => void;
}
export interface PollerDeps extends CollectDeps {
ctx: PluginContext;
log: MetricsLogger;
repository: HostMetricsRepository;
}
/**
* Heavy SSH metrics for hosts somebody is looking at. Polling starts with the
* first viewer and stops with the last one; a viewer is a tab, a dashboard
* card, or another plugin through the "host-metrics.viewers" service.
*/
export class MetricsPoller {
private readonly polled = new Map<number, Polled>();
private readonly metrics = new Map<
number,
{ data: CollectedMetrics; timestamp: number }
>();
private readonly viewers = new Map<number, Set<string>>();
private readonly viewerDetails = new Map<string, MetricsViewer>();
private readonly inFlight = new Set<number>();
private readonly initialRequested = new Set<number>();
private readonly collectionState = new Map<number, CollectionState>();
constructor(private readonly deps: PollerDeps) {}
private get ctx() {
return this.deps.ctx;
}
private get state(): MetricsState {
return this.deps.state;
}
getMetrics(hostId: number) {
return this.metrics.get(hostId);
}
isPolling(hostId: number): boolean {
return this.polled.has(hostId);
}
hasViewers(hostId: number): boolean {
return (this.viewers.get(hostId)?.size ?? 0) > 0;
}
registerViewer(hostId: number, sessionId: string, userId: string): void {
let set = this.viewers.get(hostId);
if (!set) {
set = new Set();
this.viewers.set(hostId, set);
}
set.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (set.size === 1) {
// Never let a failed start reach the request that registered it.
void this.start(hostId, userId).catch((error) => {
this.deps.log.warn("Could not start metrics polling", {
operation: "start_metrics_unhandled",
hostId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}
/** False when the viewer is gone, so the caller registers again. */
updateHeartbeat(sessionId: string, userId?: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (!viewer || (userId && viewer.userId !== userId)) return false;
viewer.lastHeartbeat = Date.now();
return true;
}
unregisterViewer(hostId: number, sessionId: string, userId?: string): void {
const viewer = this.viewerDetails.get(sessionId);
if (viewer && userId && viewer.userId !== userId) return;
const set = this.viewers.get(hostId);
if (set) {
set.delete(sessionId);
if (set.size === 0) {
this.viewers.delete(hostId);
this.stop(hostId, false);
}
}
this.viewerDetails.delete(sessionId);
}
/** Stops a host's polling without dropping its viewers. */
pause(hostId: number): void {
this.stop(hostId, false);
}
cleanupInactiveViewers(now = Date.now()): void {
for (const [sessionId, viewer] of [...this.viewerDetails.entries()]) {
if (now - viewer.lastHeartbeat > VIEWER_TIMEOUT_MS) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
/** A host's details changed: drop what was cached and start over. */
async hostUpdated(hostId: number): Promise<void> {
this.state.hostCache.invalidate(hostId);
this.state.metricsCache.clear(hostId);
this.ctx.ssh.dropPooled("stats", hostId);
const polled = this.polled.get(hostId);
if (polled) await this.start(hostId, polled.viewerUserId);
}
hostDeleted(hostId: number): void {
this.stop(hostId, true);
for (const sessionId of this.viewers.get(hostId) ?? []) {
this.viewerDetails.delete(sessionId);
}
this.viewers.delete(hostId);
this.collectionState.delete(hostId);
this.state.authFailures.reset(hostId);
this.state.backoff.reset(hostId);
}
/** Core's status check says the host answers again. */
hostStatusChanged(hostId: number, status: string): void {
if (status === "offline") return;
const polled = this.polled.get(hostId);
if (polled) this.scheduleInitialPoll(polled.host);
}
hostKeyAccepted(hostId: number): void {
this.state.authFailures.resetHostKeyFailure(hostId);
}
/** Re-times every running host, after the admin interval changed. */
async retimeAll(): Promise<void> {
for (const [hostId, polled] of [...this.polled.entries()]) {
await this.start(hostId, polled.viewerUserId);
}
}
dispose(): void {
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
this.viewers.clear();
this.viewerDetails.clear();
clearCpuSampleCache();
clearNetworkSampleCache();
}
/** The host as `userId` may reach it, cached for a few minutes. */
async resolve(hostId: number, userId: string): Promise<MetricsHost | null> {
const cached = this.state.hostCache.get(
hostId,
userId,
) as MetricsHost | null;
if (cached) return cached;
const host = (await this.ctx.asUser(userId, () =>
this.ctx.ssh.resolveHost(hostId),
)) as MetricsHost | null;
if (!host) return null;
this.state.hostCache.set(hostId, userId, host);
return host;
}
async settingsFor(hostId: number) {
const settings = readHostMetricsSettings(
await this.ctx.settings.getAll("host", hostId),
);
const adminInterval = Number(
await this.ctx.settings.get<number>("metricsInterval"),
);
return {
...settings,
intervalSeconds:
settings.metricsInterval ??
(Number.isInteger(adminInterval) && adminInterval >= 5
? adminInterval
: DEFAULT_METRICS_INTERVAL),
};
}
private async start(hostId: number, userId: string): Promise<void> {
const host = await this.resolve(hostId, userId);
this.stop(hostId, false);
if (!host || !supportsMetrics(host, this.ctx.ssh)) return;
const settings = await this.settingsFor(hostId);
if (!settings.metricsEnabled) {
this.metrics.delete(hostId);
return;
}
const intervalMs = settings.intervalSeconds * 1000;
const stop = this.ctx.schedule.every(
intervalMs,
() => {
const latest = this.polled.get(hostId);
if (latest) this.schedulePoll(latest.host);
},
// Spread timers so a fleet does not fire on the same second.
{ jitterMs: Math.min(intervalMs * 0.2, 15_000) },
);
this.polled.set(hostId, { host, viewerUserId: userId, stop });
this.syncConcurrency();
this.scheduleInitialPoll(host);
}
private stop(hostId: number, clearData: boolean): void {
const polled = this.polled.get(hostId);
if (polled) {
polled.stop();
this.polled.delete(hostId);
this.syncConcurrency();
}
this.inFlight.delete(hostId);
if (clearData) {
this.metrics.delete(hostId);
this.state.hostCache.invalidate(hostId);
clearCpuSampleCache(hostId);
clearNetworkSampleCache(hostId);
}
}
/** Keeps poll concurrency matched to how many hosts are being polled. */
private syncConcurrency(): void {
const target = metricsConcurrencyFor(this.polled.size);
const limiter = this.state.metricsLimiter;
if (target === limiter.limit) return;
const previous = limiter.limit;
limiter.setLimit(target);
this.deps.log.info(
`Metrics poll concurrency ${previous} -> ${target} for ${this.polled.size} host(s)`,
{ operation: "metrics_concurrency_resize" },
);
}
/**
* The first heavy sample waits for core's cheap status check to say the
* host answers, so a whole fleet registering at once does not open a
* burst of SSH connections to hosts that are down.
*/
private scheduleInitialPoll(host: MetricsHost): void {
if (this.metrics.has(host.id) || this.initialRequested.has(host.id)) {
return;
}
this.initialRequested.add(host.id);
void this.state.initialLimiter
.run(async () => {
let status: PluginHostStatusEntry | null = this.ctx.hosts.status.get(
host.id,
);
if (!status) status = await this.ctx.hosts.status.check(host.id);
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
return;
}
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await this.state.metricsLimiter.run(() => this.poll(host));
} finally {
this.inFlight.delete(host.id);
}
})
.catch((error) => {
this.deps.log.error("Initial metrics polling failed", error, {
operation: "initial_metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.initialRequested.delete(host.id));
}
private schedulePoll(host: MetricsHost): void {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
void this.state.metricsLimiter
.run(() => this.poll(host))
.catch((error) => {
this.deps.log.error("Metrics polling failed", error, {
operation: "metrics_poll_unhandled",
hostId: host.id,
});
})
.finally(() => this.inFlight.delete(host.id));
}
/** One sample for one host. Public for tests. */
async poll(host: MetricsHost): Promise<void> {
const { state, log } = this.deps;
if (!this.polled.has(host.id)) return;
if (state.authFailures.shouldSkip(host.id)) return;
if (state.backoff.shouldSkip(host.id)) return;
const settings = await this.settingsFor(host.id);
if (!settings.metricsEnabled) return;
let authenticated = false;
try {
const metrics = await collectMetrics(this.deps, host, settings, () => {
authenticated = true;
});
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
this.ctx.hosts.status.reportLogin(host.id, { ok: true });
this.setCollectionState(host, "collecting");
await this.recordSample(host.id, metrics);
this.ctx.events.emit(TOPIC_SNAPSHOT, {
hostId: host.id,
ownerUserId: host.userId,
metrics,
} satisfies SnapshotPayload);
state.backoff.reset(host.id);
state.authFailures.reset(host.id);
} catch (error) {
const hostKeyChanged = isHostKeyVerificationError(error);
const message = error instanceof Error ? error.message : "";
const isAuthError = /authentication|permission denied/i.test(message);
if (!authenticated) {
this.ctx.hosts.status.reportLogin(host.id, {
ok: false,
hostKeyChanged,
});
}
this.setCollectionState(
host,
hostKeyChanged
? "host_key_changed"
: isAuthError
? "auth_failed"
: "unreachable",
);
if (isAuthError) {
// collectMetrics already recorded it; log the first occurrence only.
if (!state.authFailures.shouldSkip(host.id)) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
return;
}
if (hostKeyChanged) {
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
log.error("Stats collector host key verification failed", error, {
operation: "stats_host_key_verification_failed",
hostId: host.id,
});
return;
}
state.backoff.recordFailure(host.id);
// Only log when a new retry window opens, not on every skipped poll.
const backoff = state.backoff.getBackoffInfo(host.id);
if (backoff !== null && !backoff.includes("polling suspended")) {
log.error("Stats collector connection failed", error, {
operation: "stats_connect_failed",
hostId: host.id,
});
}
}
}
private setCollectionState(host: MetricsHost, next: CollectionState): void {
const previous = this.collectionState.get(host.id) ?? null;
if (previous === next) return;
this.collectionState.set(host.id, next);
this.ctx.events.emit(TOPIC_STATUS, {
hostId: host.id,
ownerUserId: host.userId,
state: next,
previous,
} satisfies CollectionStatusPayload);
}
private async recordSample(
hostId: number,
metrics: CollectedMetrics,
): Promise<void> {
try {
const iface = metrics.network?.interfaces?.[0];
const bytes = (value: string | null | undefined) => {
const parsed = value ? parseInt(value, 10) : NaN;
return Number.isNaN(parsed) ? null : parsed;
};
const retention = Number(
await this.ctx.settings.get<number>("historyRetentionDays"),
);
await this.deps.repository.recordSample(
{
hostId,
cpuPercent: metrics.cpu?.percent ?? null,
memPercent: metrics.memory?.percent ?? null,
diskPercent: metrics.disk?.percent ?? null,
netRxBytes: bytes(iface?.rxBytes),
netTxBytes: bytes(iface?.txBytes),
},
Number.isInteger(retention) && retention >= 1
? Math.min(retention, 90)
: DEFAULT_RETENTION_DAYS,
);
} catch (error) {
this.deps.log.warn("Failed to write metrics history", {
operation: "insert_metrics_history",
hostId,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
@@ -1,230 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../../../src/types/host-metrics.js";
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
}
interface PrefHost {
id: number;
userId: string;
statsConfig?: unknown;
}
type HostMetricsPreferencesRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
export function registerHostMetricsPreferencesRoutes(
app: Express,
{
validateHostId,
fetchHostById,
parseStatsConfig,
canAccessHost,
}: HostMetricsPreferencesRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
* 404:
* description: Host not found.
*/
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const row =
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
userId,
hostId,
);
if (row?.layout) {
try {
const parsed = sanitizeLayout(JSON.parse(row.layout));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to default
}
}
const statsConfig = parseStatsConfig(host.statsConfig);
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
return res.json({ layout });
} catch (error) {
statsLogger.error("Failed to fetch host metrics preferences", {
operation: "host_metrics_prefs_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
});
/**
* @openapi
* /host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
app.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const layout = sanitizeLayout(req.body);
if (!layout) {
return res.status(400).json({ error: "Invalid layout" });
}
const now = new Date().toISOString();
const layoutJson = JSON.stringify(layout);
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
userId,
hostId,
layoutJson,
now,
);
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
// user owns. stats_config is plain JSON text (not an encrypted field).
if (host.userId === userId) {
try {
const current = parseStatsConfig(host.statsConfig);
const merged = {
...current,
enabledWidgets: deriveEnabledWidgets(layout.slots),
};
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
userId,
hostId,
JSON.stringify(merged),
);
} catch (syncErr) {
statsLogger.warn("Failed to sync enabledWidgets from layout", {
operation: "host_metrics_prefs_sync_widgets",
hostId,
error:
syncErr instanceof Error ? syncErr.message : String(syncErr),
});
}
}
return res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save host metrics preferences", {
operation: "host_metrics_prefs_save_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
}
@@ -0,0 +1,233 @@
import { and, asc, desc, eq, gte, lt, lte, notInArray } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
import {
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
hostMetricsPreferences,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands tables back untyped, and the drizzle handle is the
// server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface HealthResultInput {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
export interface HealthHistoryRow {
checkId: string;
ts: string;
ok: boolean;
latencyMs: number | null;
detail: string | null;
}
export interface MetricsHistoryInput {
hostId: number;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
export interface MetricsHistoryRow {
ts: string;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
/** Timestamps are text in `YYYY-MM-DD HH:MM:SS` UTC, which sorts in time order. */
export function sqlTimestamp(date: Date): string {
return date.toISOString().slice(0, 19).replace("T", " ");
}
export type HostMetricsRepository = Awaited<
ReturnType<typeof createHostMetricsRepository>
>;
/**
* Written without RETURNING so it runs the same on all three engines: an
* upsert reads the row by its unique (user, host) pair first.
*/
export async function createHostMetricsRepository(db: PluginDatabase) {
const preferences: Table = await db.define(hostMetricsPreferences);
const checks: Table = await db.define(hostHealthChecks);
const health: Table = await db.define(hostHealthHistory);
const history: Table = await db.define(hostMetricsHistory);
const client = () => db.client<Drizzle>();
async function findOne(table: Table, userId: string, hostId: number) {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.limit(1);
return rows[0] ?? null;
}
async function upsert(
table: Table,
userId: string,
hostId: number,
values: Record<string, unknown>,
) {
const drizzle = await client();
const now = sqlTimestamp(new Date());
const existing = await findOne(table, userId, hostId);
if (existing) {
await drizzle
.update(table)
.set({ ...values, updatedAt: now })
.where(eq(table.id, existing.id));
} else {
await drizzle.insert(table).values({
...values,
userId,
hostId,
createdAt: now,
updatedAt: now,
});
}
await db.persist();
}
return {
async findLayout(userId: string, hostId: number): Promise<string | null> {
return (await findOne(preferences, userId, hostId))?.layout ?? null;
},
saveLayout(userId: string, hostId: number, layout: string) {
return upsert(preferences, userId, hostId, { layout });
},
async findChecks(
userId: string,
hostId: number,
): Promise<{ checks: string; intervalSeconds: number } | null> {
return findOne(checks, userId, hostId);
},
saveChecks(
userId: string,
hostId: number,
json: string,
intervalSeconds: number,
) {
return upsert(checks, userId, hostId, { checks: json, intervalSeconds });
},
/** Adds results and keeps only the newest `keep` rows for the host. */
async recordHealth(
userId: string,
hostId: number,
results: HealthResultInput[],
keep: number,
): Promise<void> {
if (results.length === 0) return;
const drizzle = await client();
const ts = sqlTimestamp(new Date());
await drizzle
.insert(health)
.values(results.map((result) => ({ ...result, userId, hostId, ts })));
const scope = and(eq(health.userId, userId), eq(health.hostId, hostId));
const retained = await drizzle
.select({ id: health.id })
.from(health)
.where(scope)
.orderBy(desc(health.ts), desc(health.id))
.limit(keep);
await drizzle.delete(health).where(
retained.length
? and(
scope,
notInArray(
health.id,
retained.map((row: { id: number }) => row.id),
),
)
: scope,
);
await db.persist();
},
async listHealth(
userId: string,
hostId: number,
limit: number,
): Promise<HealthHistoryRow[]> {
const drizzle = await client();
const rows = await drizzle
.select()
.from(health)
.where(and(eq(health.userId, userId), eq(health.hostId, hostId)))
.orderBy(desc(health.ts), desc(health.id))
.limit(limit);
return rows.map((row: Record<string, unknown>) => ({
checkId: row.checkId as string,
ts: row.ts as string,
ok: row.ok === true || row.ok === 1,
latencyMs: (row.latencyMs as number | null) ?? null,
detail: (row.detail as string | null) ?? null,
}));
},
/** One sample, then drops the host's samples older than the retention. */
async recordSample(
input: MetricsHistoryInput,
retentionDays: number,
): Promise<void> {
const drizzle = await client();
const now = new Date();
await drizzle.insert(history).values({ ...input, ts: sqlTimestamp(now) });
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
await drizzle
.delete(history)
.where(
and(
eq(history.hostId, input.hostId),
lt(history.ts, sqlTimestamp(cutoff)),
),
);
// A sample every few seconds per host: let the debounced save pick it up.
await db.persist({ lazy: true });
},
async listSamples(
hostId: number,
fromTs: string,
toTs: string,
): Promise<MetricsHistoryRow[]> {
const drizzle = await client();
return drizzle
.select({
ts: history.ts,
cpuPercent: history.cpuPercent,
memPercent: history.memPercent,
diskPercent: history.diskPercent,
netRxBytes: history.netRxBytes,
netTxBytes: history.netTxBytes,
})
.from(history)
.where(
and(
eq(history.hostId, hostId),
gte(history.ts, fromTs),
lte(history.ts, toTs),
),
)
.orderBy(asc(history.ts));
},
};
}
File diff suppressed because it is too large. Load diff
+105 -45
View File
@@ -1,16 +1,19 @@
import type { Client, ConnectConfig } from "ssh2";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { MetricsLogger } from "./log.js";
/** A connection a person opened from the tab, reused by polling. */
export interface MetricsSession {
client: Client;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
hostId: number;
userId: string;
cancelCleanup?: () => void;
}
/** A connection waiting for the TOTP code the person is typing. */
export interface PendingTOTPSession {
client: Client;
finish: (responses: string[]) => void;
@@ -32,49 +35,106 @@ export interface MetricsViewer {
lastHeartbeat: number;
}
export const metricsSessions: Record<string, MetricsSession> = {};
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
const IDLE_MS = 30 * 60 * 1000;
export function cleanupMetricsSession(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.activeOperations > 0) {
statsLogger.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{
operation: "cleanup_deferred",
sessionId,
activeOperations: session.activeOperations,
},
);
scheduleMetricsSessionCleanup(sessionId);
return;
}
try {
session.client.end();
} catch {
// expected
}
clearTimeout(session.timeout);
delete metricsSessions[sessionId];
}
}
export function scheduleMetricsSessionCleanup(sessionId: string): void {
const session = metricsSessions[sessionId];
if (session) {
if (session.timeout) clearTimeout(session.timeout);
session.timeout = setTimeout(
() => {
cleanupMetricsSession(sessionId);
},
30 * 60 * 1000,
);
}
}
export function getSessionKey(hostId: number, userId: string): string {
export function sessionKey(hostId: number, userId: string): string {
return `${userId}:${hostId}`;
}
function endQuietly(client: Client): void {
try {
client.end();
} catch {
// expected
}
}
/** Interactive connections, owned by one activation of the plugin. */
export class MetricsSessions {
private readonly sessions = new Map<string, MetricsSession>();
private readonly pending = new Map<string, PendingTOTPSession>();
constructor(
private readonly schedule: PluginSchedule,
private readonly log: MetricsLogger,
) {}
get(key: string): MetricsSession | undefined {
return this.sessions.get(key);
}
/** Stores a ready connection and closes it after 30 idle minutes. */
open(key: string, session: Omit<MetricsSession, "cancelCleanup">): void {
const existing = this.sessions.get(key);
if (existing && existing.client !== session.client) this.close(key);
this.sessions.set(key, { ...session });
this.scheduleCleanup(key);
session.client.once("close", () => {
const current = this.sessions.get(key);
if (current?.client === session.client) {
current.isConnected = false;
current.cancelCleanup?.();
this.sessions.delete(key);
}
});
}
touch(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.lastActive = Date.now();
this.scheduleCleanup(key);
}
/** Closes a session, or waits for the operations still using it. */
close(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
if (session.activeOperations > 0) {
this.log.warn(
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
{ operation: "cleanup_deferred", sessionKey: key },
);
this.scheduleCleanup(key);
return;
}
session.cancelCleanup?.();
this.sessions.delete(key);
endQuietly(session.client);
}
addPending(session: PendingTOTPSession): void {
this.pending.set(session.sessionId, session);
}
getPending(sessionId: string): PendingTOTPSession | undefined {
return this.pending.get(sessionId);
}
dropPending(sessionId: string, endClient = false): void {
const session = this.pending.get(sessionId);
this.pending.delete(sessionId);
if (session && endClient) endQuietly(session.client);
}
dispose(): void {
for (const key of [...this.sessions.keys()]) {
const session = this.sessions.get(key)!;
session.cancelCleanup?.();
endQuietly(session.client);
}
this.sessions.clear();
for (const session of this.pending.values()) endQuietly(session.client);
this.pending.clear();
}
private scheduleCleanup(key: string): void {
const session = this.sessions.get(key);
if (!session) return;
session.cancelCleanup?.();
session.cancelCleanup = this.schedule.after(IDLE_MS, () => {
session.cancelCleanup = undefined;
this.close(key);
});
}
}
@@ -1,256 +0,0 @@
import type { Express, RequestHandler } from "express";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
type HostMetricsSettingsConfig = {
statusCheckInterval: number;
metricsInterval: number;
};
type HostMetricsSettingsRoutesDeps = {
requireAdmin: RequestHandler;
defaultStatsConfig: HostMetricsSettingsConfig;
refreshAllPolling: () => Promise<void>;
};
function isMissingSettingsTable(error: unknown): boolean {
return (
error instanceof Error && error.message.includes("no such table: settings")
);
}
export function registerHostMetricsSettingsRoutes(
app: Express,
{
requireAdmin,
defaultStatsConfig: DEFAULT_STATS_CONFIG,
refreshAllPolling,
}: HostMetricsSettingsRoutesDeps,
): void {
/**
* @openapi
* /global-settings:
* get:
* summary: Get global monitoring defaults
* tags:
* - Host Metrics
* responses:
* 200:
* description: Global monitoring settings.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings", requireAdmin, async (_req, res) => {
try {
const settings = createCurrentSettingsRepository();
const statusValue = await settings.get("global_status_check_interval");
const metricsValue = await settings.get("global_metrics_interval");
res.json({
statusCheckInterval: statusValue
? parseInt(statusValue, 10)
: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: metricsValue
? parseInt(metricsValue, 10)
: DEFAULT_STATS_CONFIG.metricsInterval,
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.warn("Settings table does not exist, using defaults", {
operation: "global_settings_table_check",
error: error.message,
});
return res.json({
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
});
}
statsLogger.error("Failed to fetch global settings", {
operation: "global_settings_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to fetch global settings" });
}
});
/**
* @openapi
* /global-settings:
* post:
* summary: Update global monitoring defaults
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* metricsInterval:
* type: integer
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid parameters.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings", requireAdmin, async (req, res) => {
const { statusCheckInterval, metricsInterval } = req.body;
if (
statusCheckInterval !== undefined &&
(typeof statusCheckInterval !== "number" ||
statusCheckInterval < 5 ||
statusCheckInterval > 3600)
) {
return res.status(400).json({
error: "statusCheckInterval must be between 5 and 3600 seconds",
});
}
if (
metricsInterval !== undefined &&
(typeof metricsInterval !== "number" ||
metricsInterval < 5 ||
metricsInterval > 3600)
) {
return res
.status(400)
.json({ error: "metricsInterval must be between 5 and 3600 seconds" });
}
try {
const settings = createCurrentSettingsRepository();
if (statusCheckInterval !== undefined) {
await settings.set(
"global_status_check_interval",
String(statusCheckInterval),
);
}
if (metricsInterval !== undefined) {
await settings.set("global_metrics_interval", String(metricsInterval));
}
await refreshAllPolling();
res.json({
success: true,
message: "Settings updated and polling refreshed",
});
} catch (error) {
if (isMissingSettingsTable(error)) {
statsLogger.error(
"Settings table does not exist, cannot save settings",
{
operation: "global_settings_table_check",
error: error.message,
},
);
return res.status(500).json({
error:
"Database settings table is missing. Please check database initialization.",
});
}
statsLogger.error("Failed to save global settings", {
operation: "global_settings_save_error",
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: "Failed to save global settings",
details: error instanceof Error ? error.message : String(error),
});
}
});
/**
* @openapi
* /global-settings/history:
* get:
* summary: Get metrics history retention setting
* tags:
* - Host Metrics
* responses:
* 200:
* description: Retention setting in days.
* 403:
* description: Requires admin privileges.
*/
app.get("/global-settings/history", requireAdmin, async (_req, res) => {
try {
const value = await createCurrentSettingsRepository().get(
"metrics_history_retention_days",
);
const days = value ? parseInt(value, 10) : 7;
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
} catch (error) {
statsLogger.error("Failed to fetch history retention setting", {
operation: "history_retention_fetch_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch history retention setting" });
}
});
/**
* @openapi
* /global-settings/history:
* post:
* summary: Update metrics history retention setting
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* metricsHistoryRetentionDays:
* type: integer
* minimum: 1
* maximum: 90
* responses:
* 200:
* description: Setting saved.
* 400:
* description: Invalid value.
* 403:
* description: Requires admin privileges.
*/
app.post("/global-settings/history", requireAdmin, async (req, res) => {
const { metricsHistoryRetentionDays } = req.body;
if (
typeof metricsHistoryRetentionDays !== "number" ||
metricsHistoryRetentionDays < 1 ||
metricsHistoryRetentionDays > 90
) {
return res.status(400).json({
error: "metricsHistoryRetentionDays must be between 1 and 90",
});
}
try {
await createCurrentSettingsRepository().set(
"metrics_history_retention_days",
String(Math.floor(metricsHistoryRetentionDays)),
);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save history retention setting", {
operation: "history_retention_save_error",
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to save history retention setting" });
}
});
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+439
View File
@@ -0,0 +1,439 @@
export type HostStatus = "online" | "reachable" | "offline";
export class RequestQueue {
private queues = new Map<number, Array<() => Promise<unknown>>>();
private processing = new Set<number>();
private requestTimeout = 60000;
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const wrappedRequest = async () => {
try {
const result = await Promise.race<T>([
request(),
new Promise<never>((_, rej) =>
setTimeout(
() =>
rej(
new Error(
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
),
),
this.requestTimeout,
),
),
]);
resolve(result);
} catch (error) {
reject(error);
}
};
const queue = this.queues.get(hostId) || [];
queue.push(wrappedRequest);
this.queues.set(hostId, queue);
this.processQueue(hostId);
});
}
private async processQueue(hostId: number): Promise<void> {
if (this.processing.has(hostId)) return;
this.processing.add(hostId);
const queue = this.queues.get(hostId) || [];
while (queue.length > 0) {
const request = queue.shift();
if (request) {
try {
await request();
} catch {
// expected
}
}
}
this.processing.delete(hostId);
const currentQueue = this.queues.get(hostId);
if (currentQueue && currentQueue.length > 0) {
this.processQueue(hostId);
}
}
}
interface CachedMetrics {
data: unknown;
timestamp: number;
hostId: number;
}
export class MetricsCache {
private cache = new Map<number, CachedMetrics>();
private ttl = 30000;
get(hostId: number): unknown | null {
const cached = this.cache.get(hostId);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
return null;
}
set(hostId: number, data: unknown): void {
this.cache.set(hostId, {
data,
timestamp: Date.now(),
hostId,
});
}
clear(hostId?: number): void {
if (hostId) {
this.cache.delete(hostId);
} else {
this.cache.clear();
}
}
}
interface AuthFailureRecord {
count: number;
lastFailure: number;
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
permanent: boolean;
}
export class AuthFailureTracker {
private failures = new Map<number, AuthFailureRecord>();
private maxRetries = 3;
private backoffBase = 5000;
recordFailure(
hostId: number,
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
permanent = false,
): void {
const existing = this.failures.get(hostId);
if (existing) {
existing.count++;
existing.lastFailure = Date.now();
existing.reason = reason;
if (permanent) existing.permanent = true;
} else {
this.failures.set(hostId, {
count: 1,
lastFailure: Date.now(),
reason,
permanent,
});
}
}
shouldSkip(hostId: number): boolean {
const record = this.failures.get(hostId);
if (!record) return false;
if (record.reason === "TOTP" || record.permanent) {
return true;
}
if (record.count >= this.maxRetries) {
return true;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
return timeSinceFailure < backoffTime;
}
getSkipReason(hostId: number): string | null {
const record = this.failures.get(hostId);
if (!record) return null;
if (record.reason === "TOTP") {
return "TOTP authentication required (metrics unavailable)";
}
if (record.reason === "HOST_KEY") {
return "SSH host key changed (verification required)";
}
if (record.permanent) {
return "Authentication permanently failed";
}
if (record.count >= this.maxRetries) {
return `Too many authentication failures (${record.count} attempts)`;
}
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
const timeSinceFailure = Date.now() - record.lastFailure;
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
if (timeSinceFailure < backoffTime) {
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
}
return null;
}
resetHostKeyFailure(hostId: number): void {
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, record] of this.failures.entries()) {
if (!record.permanent && now - record.lastFailure > maxAge) {
this.failures.delete(hostId);
}
}
}
}
export class PollingBackoff {
private failures = new Map<number, { count: number; nextRetry: number }>();
private baseDelay = 30000;
private maxDelay = 600000;
private maxRetries = 5;
recordFailure(hostId: number): void {
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
const delay = Math.min(
this.baseDelay * Math.pow(2, existing.count),
this.maxDelay,
);
this.failures.set(hostId, {
count: existing.count + 1,
nextRetry: Date.now() + delay,
});
}
shouldSkip(hostId: number): boolean {
const backoff = this.failures.get(hostId);
if (!backoff) return false;
if (backoff.count >= this.maxRetries) {
return true;
}
return Date.now() < backoff.nextRetry;
}
getBackoffInfo(hostId: number): string | null {
const backoff = this.failures.get(hostId);
if (!backoff) return null;
if (backoff.count >= this.maxRetries) {
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
}
const remainingMs = backoff.nextRetry - Date.now();
if (remainingMs > 0) {
const remainingSec = Math.ceil(remainingMs / 1000);
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
}
return null;
}
reset(hostId: number): void {
this.failures.delete(hostId);
}
cleanup(): void {
const maxAge = 60 * 60 * 1000;
const now = Date.now();
for (const [hostId, backoff] of this.failures.entries()) {
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
this.failures.delete(hostId);
}
}
}
}
/**
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
* Used to stop metrics polls from stampeding under load.
*/
export class ConcurrentLimiter {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private maxConcurrent: number) {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
}
get activeCount(): number {
return this.active;
}
get pendingCount(): number {
return this.waiters.length;
}
get limit(): number {
return this.maxConcurrent;
}
/**
* Changes the ceiling at runtime.
*
* Raising it wakes the waiters the new headroom allows, so a queue that built
* up under the old limit drains at once instead of one job at a time as
* running work finishes. Lowering it never interrupts work already running;
* the new limit simply applies from the next release onward.
*/
setLimit(maxConcurrent: number): void {
if (maxConcurrent < 1) {
throw new Error("maxConcurrent must be >= 1");
}
this.maxConcurrent = maxConcurrent;
this.releaseWaiters();
}
/**
* Wakes as many queued callers as there is now room for.
*
* `woken` counts callers that have been resumed but have not yet reached the
* `active += 1` on the far side of their await. Without it the occupancy
* looks lower than it really is for a microtask, and the loop would release
* past the ceiling.
*/
private releaseWaiters(): void {
while (
this.active + this.woken < this.maxConcurrent &&
this.waiters.length > 0
) {
this.woken += 1;
this.waiters.shift()!();
}
}
private woken = 0;
async run<T>(fn: () => Promise<T>): Promise<T> {
// Queue behind anything already woken, so a caller arriving mid-handoff
// cannot jump the queue and push occupancy past the ceiling.
if (this.active + this.woken >= this.maxConcurrent) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
});
// Resumed by a slot handoff; that reservation is now consumed.
this.woken = Math.max(0, this.woken - 1);
}
this.active += 1;
try {
return await fn();
} finally {
this.active -= 1;
this.releaseWaiters();
}
}
}
/**
* How many metrics polls may run at once for a given number of polled hosts.
*
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
* interval, so polling fell permanently behind and a host could wait over a
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
* its interval; the cap keeps file descriptors and CPU bounded.
*/
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
const MIN_METRICS_CONCURRENCY = 5;
const MAX_METRICS_CONCURRENCY = 50;
/** Aim to spend about a twentieth of the interval per sweep wave. */
const HOSTS_PER_WORKER = 20;
export function metricsConcurrencyFor(
hostCount: number,
env: NodeJS.ProcessEnv = process.env,
): number {
const override = Number(env[METRICS_CONCURRENCY_ENV]);
if (Number.isFinite(override) && override >= 1) {
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
}
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
return Math.min(
Math.max(scaled, MIN_METRICS_CONCURRENCY),
MAX_METRICS_CONCURRENCY,
);
}
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
export class HostPollCache<THost extends { id: number } = { id: number }> {
private cache = new Map<
number,
{ host: THost; userId: string; expiresAt: number }
>();
constructor(private readonly ttlMs = 30_000) {}
get(hostId: number, userId: string): THost | null {
const entry = this.cache.get(hostId);
if (!entry) return null;
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
this.cache.delete(hostId);
return null;
}
return entry.host;
}
set(hostId: number, userId: string, host: THost): void {
this.cache.set(hostId, {
host,
userId,
expiresAt: Date.now() + this.ttlMs,
});
}
invalidate(hostId?: number): void {
if (hostId === undefined) {
this.cache.clear();
return;
}
this.cache.delete(hostId);
}
}
/**
* The first heavy SSH sample waits until core's cheap status check says the
* host answers. A host core does not check (status checks off) is tried.
*/
export function canStartInitialMetrics(
status: HostStatus | null | undefined,
hasViewers: boolean,
): boolean {
return hasViewers && status !== "offline";
}
/** Everything one activation of the plugin polls with. */
export function createMetricsState() {
return {
/** SSH metrics execs are expensive; resized with the fleet. */
metricsLimiter: new ConcurrentLimiter(5),
/** Viewer registration is bursty; admit only two first samples at a time. */
initialLimiter: new ConcurrentLimiter(2),
hostCache: new HostPollCache(30_000),
requestQueue: new RequestQueue(),
metricsCache: new MetricsCache(),
authFailures: new AuthFailureTracker(),
backoff: new PollingBackoff(),
};
}
export type MetricsState = ReturnType<typeof createMetricsState>;
+120
View File
@@ -0,0 +1,120 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
real,
refHost,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/** One card layout per user per host, as JSON. */
export const hostMetricsPreferences = adoptLegacyTable(
"host_metrics_preferences",
defineTable(
"host_metrics_preferences",
{
id: id(),
userId: refUser(),
hostId: refHost(),
layout: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_metrics_prefs_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
/** A user's health checks for a host, as a JSON array. */
export const hostHealthChecks = adoptLegacyTable(
"host_health_checks",
defineTable(
"host_health_checks",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checks: text().notNull(),
intervalSeconds: integer().notNull().default(300),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{
name: "idx_host_health_checks_user_host",
columns: ["userId", "hostId"],
},
],
},
),
);
export const hostHealthHistory = adoptLegacyTable(
"host_health_history",
defineTable(
"host_health_history",
{
id: id(),
userId: refUser(),
hostId: refHost(),
checkId: varchar().notNull(),
ts: timestamp().notNull().defaultNow(),
ok: boolean().notNull(),
latencyMs: integer(),
detail: text(),
},
{
indexes: [
{
name: "idx_host_health_history_lookup",
columns: ["userId", "hostId", "checkId", "ts"],
},
],
},
),
);
/** One row per metrics sample, pruned to the retention setting. */
export const hostMetricsHistory = adoptLegacyTable(
"host_metrics_history",
defineTable(
"host_metrics_history",
{
id: id(),
hostId: refHost(),
ts: timestamp().notNull().defaultNow(),
cpuPercent: real(),
memPercent: real(),
diskPercent: real(),
netRxBytes: integer(),
netTxBytes: integer(),
},
{
indexes: [
{
name: "idx_host_metrics_history_host_ts",
columns: ["hostId", "ts"],
},
],
},
),
);
export const tables = [
hostMetricsPreferences,
hostHealthChecks,
hostHealthHistory,
hostMetricsHistory,
];
+17
View File
@@ -0,0 +1,17 @@
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
export function errorMessage(error: unknown, fallback = "Unknown error") {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
@@ -1,291 +0,0 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import type { Express } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
type ViewerStatsConfig = {
metricsEnabled: boolean;
};
type HostMetricsViewerRoutesDeps<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
> = {
fetchHostById: (hostId: number, userId: string) => Promise<THost>;
supportsMetrics: (host: THost) => boolean;
parseStatsConfig: (statsConfig: THost["statsConfig"]) => TStatsConfig;
updateHeartbeat: (viewerSessionId: string) => boolean;
registerViewer: (
hostId: number,
viewerSessionId: string,
userId: string,
) => void;
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
/** Route path segment, e.g. "metrics" -> /metrics/heartbeat. Defaults to "metrics". */
pathPrefix?: string;
};
export function registerHostMetricsViewerRoutes<
THost extends { statsConfig?: string | TStatsConfig },
TStatsConfig extends ViewerStatsConfig,
>(
app: Express,
{
fetchHostById,
supportsMetrics,
parseStatsConfig,
updateHeartbeat,
registerViewer,
unregisterViewer,
pathPrefix = "metrics",
}: HostMetricsViewerRoutesDeps<THost, TStatsConfig>,
): void {
/**
* @openapi
* /metrics/heartbeat:
* post:
* summary: Update viewer heartbeat
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Heartbeat updated successfully.
* 400:
* description: Invalid viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Viewer session not found.
* 500:
* description: Failed to update heartbeat.
*/
app.post(`/${pathPrefix}/heartbeat`, async (req, res) => {
const { viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
const success = updateHeartbeat(viewerSessionId);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: "Viewer session not found" });
}
} catch (error) {
statsLogger.error("Failed to update heartbeat", {
operation: "heartbeat_error",
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to update heartbeat" });
}
});
/**
* @openapi
* /metrics/register-viewer:
* post:
* summary: Register metrics viewer
* description: Registers a new viewer session for a host to track who is viewing metrics.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Viewer registered successfully.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to register viewer.
*/
app.post(`/${pathPrefix}/register-viewer`, async (req, res) => {
const { hostId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
try {
// Graceful no-op if host is inaccessible, metrics disabled, or host type
// does not support metrics. The client may call this speculatively, so
// avoid returning 5xx for expected "no metrics available" scenarios.
let host: THost | undefined;
try {
host = await fetchHostById(hostId, userId);
} catch (lookupErr) {
statsLogger.warn(
"register-viewer host lookup failed (treating as no-op)",
{
operation: "register_viewer_lookup",
hostId,
userId,
error: getErrorMessage(lookupErr, String(lookupErr)),
},
);
}
if (!host) {
return res.json({
success: true,
skipped: true,
reason: "host_not_found",
});
}
if (!supportsMetrics(host)) {
return res.json({
success: true,
skipped: true,
reason: "metrics_unsupported",
});
}
const statsConfig = parseStatsConfig(host.statsConfig);
if (!statsConfig.metricsEnabled) {
return res.json({
success: true,
skipped: true,
reason: "metrics_disabled",
});
}
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
registerViewer(hostId, viewerSessionId, userId);
} catch (regErr) {
statsLogger.warn(
"pollingManager.registerViewer threw (treating as no-op)",
{
operation: "register_viewer_internal",
hostId,
userId,
error: regErr instanceof Error ? regErr.message : String(regErr),
},
);
return res.json({
success: true,
skipped: true,
reason: "register_failed_noop",
});
}
res.json({ success: true, viewerSessionId });
} catch (error) {
statsLogger.error("Failed to register viewer", {
operation: "register_viewer_error",
hostId,
userId,
error: error instanceof Error ? error.message : String(error),
});
// Even on unexpected errors we prefer a graceful client experience: the
// viewer-registration is purely an optimization and should never break
// the UI. Report success:false but HTTP 200 so the client can decide.
res.status(200).json({
success: false,
skipped: true,
reason: "internal_error",
});
}
});
/**
* @openapi
* /metrics/unregister-viewer:
* post:
* summary: Unregister metrics viewer
* description: Unregisters a viewer session when they stop viewing metrics for a host.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Viewer unregistered successfully.
* 400:
* description: Invalid hostId or viewerSessionId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to unregister viewer.
*/
app.post(`/${pathPrefix}/unregister-viewer`, async (req, res) => {
const { hostId, viewerSessionId } = req.body;
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
try {
unregisterViewer(hostId, viewerSessionId);
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to unregister viewer", {
operation: "unregister_viewer_error",
hostId,
viewerSessionId,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({ error: "Failed to unregister viewer" });
}
});
}
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
/**
* CPU percent is derived from the delta against the previous poll's
@@ -1,10 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
@@ -1,10 +1,10 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../../../src/types/stats-widgets.js";
} from "../../shared/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
@@ -1,8 +1,6 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface GpuDevice {
index: number;
@@ -1,5 +1,5 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface LoginRecord {
user: string;
@@ -1,11 +1,10 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
toFixedNum,
kibToGiB,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import { toFixedNum, kibToGiB } from "../util.js";
import type { Client } from "ssh2";
async function collectDarwinMemoryMetrics(client: Client): Promise<{
percent: number | null;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
export interface NetworkCounters {
rx: string;
@@ -1,9 +1,9 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import type {
PortsMetrics,
ListeningPort,
} from "../../../../../src/types/stats-widgets.js";
} from "../../shared/stats-widgets.js";
function parseSsOutput(output: string): ListeningPort[] {
const ports: ListeningPort[] = [];
@@ -1,5 +1,5 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
async function collectWindowsSystemMetrics(client: Client): Promise<{
hostname: string | null;
@@ -1,8 +1,6 @@
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { toFixedNum } from "../util.js";
import type { Client } from "ssh2";
import {
execCommand,
toFixedNum,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
export interface TemperatureSensor {
label: string;
@@ -1,9 +1,9 @@
import type { Client } from "ssh2";
import {
execCommand,
execPowerShell,
type HostPlatform,
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
} from "@termix/plugin-sdk/host-commands";
import type { Client } from "ssh2";
function formatUptime(uptimeSeconds: number): string {
const days = Math.floor(uptimeSeconds / 86400);
@@ -1,7 +1,18 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Activity,
Button,
Input,
SectionCard,
SettingRow,
FakeSwitch,
Select2,
} from "@termix/plugin-sdk/ui";
import { useEffect, useState } from "react";
import {
usePluginApi,
useTranslation,
type HostEditorSectionProps,
} from "@termix/plugin-sdk/frontend";
import {
HardDrive,
LayoutDashboard,
Plus,
@@ -10,32 +21,66 @@ import {
Zap,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
const PLUGIN_ID = "host-metrics";
type PluginSettingsBag = Record<string, Record<string, unknown>>;
type QuickActionRow = { name: string; snippetId: string };
/**
* The Host Metrics section of the host editor. Its values are this plugin's
* host settings, carried on form.pluginSettings and saved by the editor after
* the host itself. Quick actions are a core host field.
*/
export function HostStatsTab({
form,
setField,
updateForm,
host,
snippets,
}: {
form: HostEditorForm;
setField: SetHostField;
snippets: { id: number; name: string }[];
}) {
}: HostEditorSectionProps) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const excludedMounts = form.statsConfig.excludedMounts ?? [];
const monitoredMounts = form.statsConfig.monitoredMounts ?? [];
const bag = (form.pluginSettings as PluginSettingsBag | undefined)?.[
PLUGIN_ID
];
const settings = readHostMetricsSettings(bag);
const { excludedMounts, monitoredMounts } = settings;
const quickActions = (form.quickActions ?? []) as QuickActionRow[];
const snippetOptions = (snippets ?? []) as { id: number; name: string }[];
const api = usePluginApi();
const patch = (values: Record<string, unknown>) =>
updateForm((current) => {
const all =
(current.pluginSettings as PluginSettingsBag | undefined) ?? {};
return {
pluginSettings: {
...all,
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
},
};
});
// A new host starts with the admin's default for metrics.
useEffect(() => {
if (host || bag?.metricsEnabled !== undefined) return;
let cancelled = false;
api
.get<{ enabledForNewHosts: boolean }>("/defaults")
.then(({ data }) => {
if (!cancelled && data.enabledForNewHosts === false) {
patch({ metricsEnabled: false });
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [host]);
const addExcludedMount = () => {
const value = newMount.trim();
@@ -43,18 +88,14 @@ export function HostStatsTab({
setNewMount("");
return;
}
setField("statsConfig", {
...form.statsConfig,
excludedMounts: [...excludedMounts, value],
});
patch({ excludedMounts: [...excludedMounts, value] });
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
setField("statsConfig", {
...form.statsConfig,
patch({
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
@@ -66,62 +107,6 @@ export function HostStatsTab({
return (
<>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
@@ -132,50 +117,36 @@ export function HostStatsTab({
description={t("hosts.enableMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.metricsEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
metricsEnabled: v,
})
}
checked={settings.metricsEnabled}
onChange={(v) => patch({ metricsEnabled: v })}
/>
</SettingRow>
{form.statsConfig.metricsEnabled && (
{settings.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
checked={settings.metricsInterval === null}
onChange={(v) => patch({ metricsInterval: v ? null : 30 })}
/>
</SettingRow>
)}
{settings.metricsEnabled && settings.metricsInterval !== null && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={settings.metricsInterval ?? 30}
onChange={(e) =>
patch({ metricsInterval: Number(e.target.value) })
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
{form.statsConfig.metricsEnabled &&
!form.statsConfig.useGlobalMetricsInterval && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={form.statsConfig.metricsInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
metricsInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
@@ -236,8 +207,7 @@ export function HostStatsTab({
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
patch({
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
@@ -295,8 +265,7 @@ export function HostStatsTab({
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
patch({
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
@@ -329,7 +298,7 @@ export function HostStatsTab({
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("quickActions", [
...form.quickActions,
...quickActions,
{ name: "", snippetId: "" },
])
}
@@ -342,13 +311,13 @@ export function HostStatsTab({
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</p>
{form.quickActions.length === 0 && (
{quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("hosts.noQuickActions")}</span>
</div>
)}
{form.quickActions.map((a, i) => (
{quickActions.map((a, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
@@ -358,7 +327,7 @@ export function HostStatsTab({
placeholder={t("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...form.quickActions];
const updated = [...quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
@@ -367,7 +336,7 @@ export function HostStatsTab({
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...form.quickActions];
const updated = [...quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
@@ -376,7 +345,7 @@ export function HostStatsTab({
}}
>
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
{snippets.map((s) => (
{snippetOptions.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
@@ -387,7 +356,7 @@ export function HostStatsTab({
onClick={() =>
setField(
"quickActions",
form.quickActions.filter((_, idx) => idx !== i),
quickActions.filter((_, idx) => idx !== i),
)
}
>
@@ -1,8 +1,7 @@
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { HostMetricsTab } from "./HostMetricsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface HostMetricsAppProps {
hostId?: string;
@@ -1,25 +1,40 @@
import { getErrorMessage } from "@/lib/error-message.js";
import {
Separator,
Button,
useAreaPreferences,
useConfirmation,
TOTPDialog,
useTabsSafe,
ConnectionLogProvider,
useConnectionLog,
ConnectionScreen,
runAdaptivePolling,
CardGridCanvas,
ColumnCountStepper,
type GridCardCatalogEntry,
logActivity,
SnippetVariablesDialog,
} from "@termix/plugin-sdk/ui";
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../shared/host-metrics.js";
import type { ServerMetrics } from "../shared/metrics.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Button } from "@/components/button.tsx";
import {
getServerStatusById,
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
submitMetricsTOTP,
executeSnippet,
getSnippets,
logActivity,
sendMetricsHeartbeat,
getSSHHosts,
type ServerMetrics,
} from "@/main-axios.ts";
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import type { Snippet } from "@/types/ui-types.ts";
import { useHostMetricsApi } from "./host-metrics-api";
interface Snippet {
id: number;
name: string;
content: string;
}
function errorText(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return typeof error === "string" ? error : "";
}
// Mirrors the snippets plugin's own hasSnippetInputs: a pure check for
// whether a snippet still has $INPUT_n placeholders once host variables are
@@ -30,35 +45,16 @@ function hasSnippetInputs(content: string): boolean {
SNIPPET_INPUT_PATTERN.lastIndex = 0;
return SNIPPET_INPUT_PATTERN.test(content);
}
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import {
useTranslation,
useSlotContributions,
useConnectionRetry,
useHost,
useHostStatus,
invokeAction,
} from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets.ts";
import {
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "@/types/host-metrics.ts";
import { RefreshCw, Server, LayoutDashboard } from "lucide-react";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
import type { LogEntry } from "@/types/connection-log.ts";
import {
CardGridCanvas,
ColumnCountStepper,
} from "@/components/card-grid/CardGridCanvas.tsx";
import type { GridCardCatalogEntry } from "@/components/card-grid/types.ts";
import { useHostMetricsPreferences } from "./hooks/useHostMetricsPreferences.ts";
import {
CARD_DEFINITIONS,
@@ -79,7 +75,9 @@ interface QuickAction {
snippetId: number;
}
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
type ConnectionLogPayload = Parameters<
ReturnType<typeof useConnectionLog>["addLog"]
>[0];
type ConnectionLogError = Error & {
connectionLogs?: ConnectionLogPayload[];
};
@@ -90,7 +88,8 @@ interface HostConfig {
ip: string;
username: string;
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
statusCheckEnabled?: boolean;
pluginSettings?: Record<string, Record<string, unknown>>;
authType?: string;
port?: number;
[key: string]: unknown;
@@ -104,16 +103,6 @@ interface HostMetricsProps {
embedded?: boolean;
}
function parseStatsConfig(raw?: string | StatsConfig): StatsConfig {
if (!raw) return DEFAULT_STATS_CONFIG;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch {
return DEFAULT_STATS_CONFIG;
}
}
function HostMetricsInner({
hostConfig,
title,
@@ -122,6 +111,7 @@ function HostMetricsInner({
embedded = false,
}: HostMetricsProps): React.ReactElement {
const { t } = useTranslation();
const metricsApi = useHostMetricsApi();
const { addLog, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
@@ -157,14 +147,16 @@ function HostMetricsInner({
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const snippetsCacheRef = React.useRef<Snippet[] | null>(null);
const snippetsCacheRef = React.useRef(new Map<number, Snippet>());
const statsConfig = React.useMemo(
() => parseStatsConfig(currentHostConfig?.statsConfig),
[currentHostConfig?.statsConfig],
);
const statsConfig = React.useMemo(() => {
const settings = readHostMetricsSettings(
currentHostConfig?.pluginSettings?.["host-metrics"],
);
return { ...settings, metricsInterval: settings.metricsInterval ?? 30 };
}, [currentHostConfig?.pluginSettings]);
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const statusCheckEnabled = currentHostConfig?.statusCheckEnabled !== false;
const hostId = currentHostConfig?.id ?? null;
const { layout, setLayout } = useHostMetricsPreferences(hostId);
@@ -245,7 +237,7 @@ function HostMetricsInner({
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const interval = setInterval(() => {
sendMetricsHeartbeat(viewerSessionId).catch(() => {});
metricsApi.heartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
@@ -297,7 +289,7 @@ function HostMetricsInner({
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await submitMetricsTOTP(totpSessionId, totpCode);
const result = await metricsApi.submitTotp(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
setTotpSessionId(null);
@@ -314,66 +306,33 @@ function HostMetricsInner({
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
await stopMetricsPolling(currentHostConfig.id).catch(() => {});
await metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
}
if (currentTab !== null) removeTab(currentTab);
};
// The shell keeps the host list current, including plugin settings.
const latestHost = useHost(hostConfig?.id);
React.useEffect(() => {
const fetchLatest = async () => {
if (!hostConfig?.id) return;
try {
const hosts = await getSSHHosts();
const updated = hosts.find((h) => h.id === hostConfig.id);
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
} catch {
toast.error(t("hostMetrics.failedToFetchHostConfig"));
}
};
fetchLatest();
const onChanged = () => fetchLatest();
window.addEventListener("ssh-hosts:changed", onChanged);
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
}, [hostConfig?.id]);
if (latestHost) {
setCurrentHostConfig({
...(latestHost as unknown as HostConfig),
id: Number(latestHost.id),
});
}
}, [latestHost]);
// Core's status check, the same one behind the host list's dot.
const hostStatus = useHostStatus(hostId ?? undefined);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id || !isActuallyVisible) {
if (!statusCheckEnabled) setServerStatus("offline");
if (!statusCheckEnabled) {
setServerStatus("offline");
return;
}
let cancelled = false;
let lastStatus = serverStatus;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig.id);
const nextStatus = res?.status === "online" ? "online" : "offline";
if (!cancelled) {
setServerStatus(nextStatus);
}
const changed = nextStatus !== lastStatus;
lastStatus = nextStatus;
return changed;
} catch {
if (!cancelled) setServerStatus("offline");
throw new Error("Host status check failed");
}
};
const minIntervalMs = statsConfig.statusCheckInterval * 1000;
const stop = runAdaptivePolling(fetchStatus, {
minIntervalMs,
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
stablePollsPerStep: 3,
});
return () => {
cancelled = true;
stop();
};
}, [
currentHostConfig?.id,
statusCheckEnabled,
statsConfig.statusCheckInterval,
isActuallyVisible,
]);
if (hostStatus) {
setServerStatus(hostStatus.status === "online" ? "online" : "offline");
}
}, [hostStatus?.status, statusCheckEnabled]);
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
@@ -391,7 +350,7 @@ function HostMetricsInner({
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
const result = await metricsApi.startMetrics(currentHostConfig.id);
result?.connectionLogs?.forEach((log) =>
addLog(log as ConnectionLogPayload),
);
@@ -409,10 +368,10 @@ function HostMetricsInner({
// collected asynchronously on the backend (status check, then metrics
// exec), so it isn't ready the instant the connection succeeds. Give it
// a few short retries before treating the initial fetch as a failure.
let data = await getServerMetricsById(currentHostConfig.id);
let data = await metricsApi.getMetrics(currentHostConfig.id);
for (let i = 0; !data && i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 1500));
data = await getServerMetricsById(currentHostConfig.id);
data = await metricsApi.getMetrics(currentHostConfig.id);
}
if (!data) {
throw new Error(t("hostMetrics.connectionFailed"));
@@ -433,7 +392,7 @@ function HostMetricsInner({
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = runAdaptivePolling(
async () => {
const next = await getServerMetricsById(currentHostConfig.id);
const next = await metricsApi.getMetrics(currentHostConfig.id);
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
const nextSignature = metricsChangeKey(next);
const changed = nextSignature !== signature;
@@ -458,6 +417,7 @@ function HostMetricsInner({
currentTab,
removeTab,
pushHistory,
metricsApi,
]);
const metricsRetry = useConnectionRetry({
@@ -506,10 +466,9 @@ function HostMetricsInner({
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
).catch(() => {});
await metricsApi
.stopMetrics(currentHostConfig.id, viewerSessionId || undefined)
.catch(() => {});
}
};
@@ -530,7 +489,7 @@ function HostMetricsInner({
stopMetricsPollingRef.current?.();
stopMetricsPollingRef.current = null;
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
metricsApi.stopMetrics(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, isPageVisible, metricsEnabled]);
@@ -559,9 +518,7 @@ function HostMetricsInner({
}
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === "online" ? "online" : "offline");
const data = await getServerMetricsById(currentHostConfig.id);
const data = await metricsApi.getMetrics(currentHostConfig.id);
if (data) {
setMetrics(data);
pushHistory(data);
@@ -586,12 +543,19 @@ function HostMetricsInner({
},
);
try {
const result = await executeSnippet(
const result = (await invokeAction(
"snippets.execute",
action.snippetId,
currentHostConfig.id,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
if (result.success) {
)) as { success: boolean; output?: string; error?: string } | undefined;
if (!result) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: t("hostMetrics.snippetsUnavailable"),
duration: 5000,
});
} else if (result.success) {
toast.success(
t("hostMetrics.quickActionSuccess", { name: action.name }),
{
@@ -610,7 +574,7 @@ function HostMetricsInner({
} catch (error) {
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
id: `quick-action-${action.snippetId}`,
description: getErrorMessage(error),
description: errorText(error),
duration: 5000,
});
} finally {
@@ -645,13 +609,13 @@ function HostMetricsInner({
async function runQuickAction(action: QuickAction) {
if (!currentHostConfig) return;
try {
if (!snippetsCacheRef.current) {
snippetsCacheRef.current =
(await getSnippets()) as unknown as Snippet[];
let snippet = snippetsCacheRef.current.get(action.snippetId);
if (!snippet) {
snippet =
((await invokeAction("snippets.get", action.snippetId)) as
Snippet | null | undefined) ?? undefined;
if (snippet) snippetsCacheRef.current.set(snippet.id, snippet);
}
const snippet = snippetsCacheRef.current?.find(
(s) => s.id === action.snippetId,
);
if (snippet && hasSnippetInputs(snippet.content)) {
setRunningAction({ action, snippet });
return;
@@ -843,7 +807,11 @@ function HostMetricsInner({
{runningAction && (
<SnippetVariablesDialog
snippet={runningAction.snippet}
snippet={
runningAction.snippet as Parameters<
typeof SnippetVariablesDialog
>[0]["snippet"]
}
host={
currentHostConfig
? {
@@ -1,13 +1,11 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import type {
MetricsChartConfig,
MetricsChartMetric,
MetricsChartRange,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { Select2 } from "@/components/select2";
} from "../shared/homepage.js";
const METRICS: { id: MetricsChartMetric; label: string }[] = [
{ id: "cpu", label: "CPU" },
@@ -29,13 +27,11 @@ export function MetricsChartEditForm({
onChange,
}: WidgetEditFormProps<MetricsChartConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
const { hosts: records } = useHosts();
const hosts = records.map((host) => ({
id: Number(host.id),
name: host.name,
}));
return (
<div className="flex flex-col gap-3">
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff. Show more