mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
1 parent
14eb558b85
commit
0a3f05d99d
308 files changed
+53073
-8985
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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": "已連線"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginSsh } from "@termix/plugin-sdk/backend";
|
||||
import { detectHostPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
|
||||
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
|
||||
import { collectDiskMetrics } from "./widgets/disk-collector.js";
|
||||
import { collectNetworkMetrics } from "./widgets/network-collector.js";
|
||||
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
|
||||
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
|
||||
import { collectSystemMetrics } from "./widgets/system-collector.js";
|
||||
import { collectLoginStats } from "./widgets/login-stats-collector.js";
|
||||
import { collectPortsMetrics } from "./widgets/ports-collector.js";
|
||||
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
|
||||
import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
|
||||
import { collectGpuMetrics, type GpuMetrics } from "./widgets/gpu-collector.js";
|
||||
import type { FirewallMetrics, PortsMetrics } from "../shared/stats-widgets.js";
|
||||
import { supportsMetrics, type MetricsHost } from "./helpers.js";
|
||||
import { sessionKey, type MetricsSessions } from "./sessions.js";
|
||||
import type { MetricsState } from "./state.js";
|
||||
import type { CollectorRegistry } from "./collectors.js";
|
||||
|
||||
export function isHostKeyVerificationError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message.includes("Host denied (verification failed)") ||
|
||||
error.message.includes("Host key changed"))
|
||||
);
|
||||
}
|
||||
|
||||
export type CollectedMetrics = {
|
||||
cpu: Awaited<ReturnType<typeof collectCpuMetrics>>;
|
||||
memory: Awaited<ReturnType<typeof collectMemoryMetrics>>;
|
||||
disk: Awaited<ReturnType<typeof collectDiskMetrics>>;
|
||||
network: Awaited<ReturnType<typeof collectNetworkMetrics>>;
|
||||
uptime: Awaited<ReturnType<typeof collectUptimeMetrics>>;
|
||||
processes: Awaited<ReturnType<typeof collectProcessesMetrics>>;
|
||||
system: Awaited<ReturnType<typeof collectSystemMetrics>>;
|
||||
login_stats: Awaited<ReturnType<typeof collectLoginStats>>;
|
||||
ports: PortsMetrics;
|
||||
firewall: FirewallMetrics;
|
||||
temperature: Awaited<ReturnType<typeof collectTemperatureMetrics>>;
|
||||
gpu: GpuMetrics;
|
||||
/** Results from other plugins' collectors, by collector id. */
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface CollectDeps {
|
||||
ssh: PluginSsh;
|
||||
state: MetricsState;
|
||||
sessions: MetricsSessions;
|
||||
collectors: CollectorRegistry;
|
||||
}
|
||||
|
||||
async function optional<T>(collect: () => Promise<T>, fallback: T) {
|
||||
try {
|
||||
return await collect();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One sample. Reuses a connection the person opened from the tab when there
|
||||
* is one, otherwise borrows the "stats" pool, which answers password prompts
|
||||
* only and fails fast on anything that needs a person.
|
||||
*/
|
||||
export async function collectMetrics(
|
||||
deps: CollectDeps,
|
||||
host: MetricsHost,
|
||||
mounts: {
|
||||
excludedMounts: string[];
|
||||
monitoredMounts: Array<{ path: string; label?: string }>;
|
||||
},
|
||||
onAuthenticated?: () => void,
|
||||
): Promise<CollectedMetrics> {
|
||||
const { ssh, state, sessions, collectors } = deps;
|
||||
if (!supportsMetrics(host, ssh)) {
|
||||
throw new Error("Metrics collection only supported for SSH hosts");
|
||||
}
|
||||
|
||||
if (state.authFailures.shouldSkip(host.id)) {
|
||||
const reason = state.authFailures.getSkipReason(host.id);
|
||||
throw new Error(reason || "Authentication failed");
|
||||
}
|
||||
|
||||
const cached = state.metricsCache.get(host.id) as CollectedMetrics | null;
|
||||
if (cached) {
|
||||
onAuthenticated?.();
|
||||
return cached;
|
||||
}
|
||||
|
||||
return state.requestQueue.queueRequest(host.id, async () => {
|
||||
const key = sessionKey(host.id, host.userId);
|
||||
const existing = sessions.get(key);
|
||||
|
||||
try {
|
||||
const collect = async (client: Client): Promise<CollectedMetrics> => {
|
||||
onAuthenticated?.();
|
||||
const platform = await detectHostPlatform(client);
|
||||
const result: CollectedMetrics = {
|
||||
cpu: await collectCpuMetrics(client, platform, host.id),
|
||||
memory: await collectMemoryMetrics(client, platform),
|
||||
disk: await collectDiskMetrics(
|
||||
client,
|
||||
mounts.excludedMounts,
|
||||
mounts.monitoredMounts,
|
||||
platform,
|
||||
),
|
||||
network: await collectNetworkMetrics(client, platform, host.id),
|
||||
uptime: await collectUptimeMetrics(client, platform),
|
||||
processes: await collectProcessesMetrics(client),
|
||||
system: await collectSystemMetrics(client, platform),
|
||||
login_stats: await optional(() => collectLoginStats(client), {
|
||||
recentLogins: [],
|
||||
failedLogins: [],
|
||||
totalLogins: 0,
|
||||
uniqueIPs: 0,
|
||||
}),
|
||||
ports: await optional(() => collectPortsMetrics(client), {
|
||||
source: "none",
|
||||
ports: [],
|
||||
}),
|
||||
firewall: await optional(() => collectFirewallMetrics(client), {
|
||||
type: "none",
|
||||
status: "unknown",
|
||||
chains: [],
|
||||
}),
|
||||
temperature: await optional(() => collectTemperatureMetrics(client), {
|
||||
source: "none",
|
||||
highestCelsius: null,
|
||||
sensors: [],
|
||||
}),
|
||||
gpu: await optional(() => collectGpuMetrics(client), {
|
||||
source: "none",
|
||||
gpus: [],
|
||||
processes: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const extra = await collectors.run(client, host.id, host.userId);
|
||||
if (Object.keys(extra).length > 0) result.extra = extra;
|
||||
|
||||
state.metricsCache.set(host.id, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
if (existing?.isConnected) {
|
||||
existing.activeOperations++;
|
||||
try {
|
||||
const result = await collect(existing.client);
|
||||
sessions.touch(key);
|
||||
return result;
|
||||
} finally {
|
||||
existing.activeOperations--;
|
||||
}
|
||||
}
|
||||
|
||||
return await ssh.withConnection<CollectedMetrics, Client>(
|
||||
host,
|
||||
{
|
||||
pool: "stats",
|
||||
purpose: "metrics",
|
||||
overrides: { readyTimeout: 60000 },
|
||||
},
|
||||
collect,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("TOTP authentication required")) {
|
||||
throw error;
|
||||
} else if (
|
||||
error.message.includes("No password available") ||
|
||||
error.message.includes("Unsupported authentication type") ||
|
||||
error.message.includes("No SSH key available") ||
|
||||
error.message.includes("Invalid SSH key format")
|
||||
) {
|
||||
state.authFailures.recordFailure(host.id, "AUTH", true);
|
||||
} else if (isHostKeyVerificationError(error)) {
|
||||
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
|
||||
} else if (
|
||||
error.message.includes("authentication") ||
|
||||
error.message.includes("Permission denied") ||
|
||||
error.message.includes("All configured authentication methods failed")
|
||||
) {
|
||||
state.authFailures.recordFailure(host.id, "AUTH");
|
||||
} else if (
|
||||
error.message.includes("timeout") ||
|
||||
error.message.includes("ETIMEDOUT")
|
||||
) {
|
||||
state.authFailures.recordFailure(host.id, "TIMEOUT");
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
|
||||
export const COLLECTORS_SERVICE = "host-metrics.collectors";
|
||||
|
||||
/**
|
||||
* The "host-metrics.collectors" service, version 1. Another plugin provides
|
||||
* it under its own provider name (manifest `provides[].names`) to add data
|
||||
* to every sample. It runs on the sample's SSH connection, as the host's
|
||||
* owner, and its result lands in the snapshot under `extra[<name>]`. A
|
||||
* collector that throws is skipped for that sample.
|
||||
*/
|
||||
export interface MetricsCollectorV1 {
|
||||
collect: (client: Client, hostId: number) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export class CollectorRegistry {
|
||||
constructor(
|
||||
private readonly services: PluginServices,
|
||||
private readonly log: MetricsLogger,
|
||||
) {}
|
||||
|
||||
async run(
|
||||
client: Client,
|
||||
hostId: number,
|
||||
ownerUserId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const results: Record<string, unknown> = {};
|
||||
for (const provider of this.services.providers(COLLECTORS_SERVICE)) {
|
||||
if (!provider) continue;
|
||||
try {
|
||||
const collector = this.services.get<Partial<MetricsCollectorV1>>(
|
||||
COLLECTORS_SERVICE,
|
||||
{ provider, userId: ownerUserId },
|
||||
);
|
||||
if (typeof collector.collect !== "function") continue;
|
||||
results[provider] = await collector.collect(client, hostId);
|
||||
} catch (error) {
|
||||
this.log.debug(`Metrics collector ${provider} failed`, {
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +1,51 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { pluginSsh } from "./ssh.js";
|
||||
import type { PluginSsh, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
authType?: string;
|
||||
/** A host as ctx.ssh.resolveHost returns it, with the fields polling reads. */
|
||||
export type MetricsHost = PluginSshHost & {
|
||||
userId: string;
|
||||
name?: string | null;
|
||||
connectionType?: string | null;
|
||||
enableSsh?: boolean | null;
|
||||
password?: string | null;
|
||||
sudoPassword?: string | null;
|
||||
terminalConfig?: { sudoPassword?: string | null } | null;
|
||||
enableDocker?: boolean | null;
|
||||
jumpHosts?: Array<{ hostId: number }> | null;
|
||||
};
|
||||
|
||||
export type TcpPingStatsConfig = {
|
||||
statusCheckEnabled: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
};
|
||||
|
||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||
/** Metrics run over SSH, unattended, so the auth type has to allow that. */
|
||||
export function supportsMetrics(
|
||||
host: Pick<MetricsHost, "connectionType" | "enableSsh" | "authType">,
|
||||
ssh: Pick<PluginSsh, "supportsBackground">,
|
||||
): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
return pluginSsh().supportsBackground(host.authType || "none");
|
||||
if (connectionType !== "ssh" && host.enableSsh !== true) return false;
|
||||
return ssh.supportsBackground(host.authType || "none");
|
||||
}
|
||||
|
||||
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||
export function sudoPasswordOf(host: MetricsHost): string | undefined {
|
||||
return host.sudoPassword || host.terminalConfig?.sudoPassword || undefined;
|
||||
}
|
||||
|
||||
export function parseStatusHostIds(value: unknown): Set<number> | null {
|
||||
if (value === undefined) return null;
|
||||
if (typeof value !== "string") return new Set();
|
||||
export type ConnectionLogType = "info" | "success" | "warning" | "error";
|
||||
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((id) => Number.isSafeInteger(id) && id > 0),
|
||||
);
|
||||
export interface ConnectionLog {
|
||||
type: ConnectionLogType;
|
||||
stage: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
/** The entry shape the connection log panel reads. */
|
||||
export function connectionLog(
|
||||
type: ConnectionLogType,
|
||||
stage: string,
|
||||
message: string,
|
||||
details?: Record<string, unknown>,
|
||||
): ConnectionLog {
|
||||
return { type, stage, message, ...(details ? { details } : {}) };
|
||||
}
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
});
|
||||
});
|
||||
export function newSessionId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { createCurrentHostMetricsHistoryRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
type HistoryRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
const RANGE_OFFSETS: Record<string, number> = {
|
||||
"1h": 1 * 60 * 60 * 1000,
|
||||
"6h": 6 * 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
export function registerHostMetricsHistoryRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, canAccessHost }: HistoryRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/history/{id}:
|
||||
* get:
|
||||
* summary: Get historical metrics for a host
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: range
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [1h, 6h, 24h, 7d, 30d]
|
||||
* - in: query
|
||||
* name: from
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* - in: query
|
||||
* name: to
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of metric history rows.
|
||||
* 403:
|
||||
* description: Access denied.
|
||||
* 404:
|
||||
* description: Host not found or no access.
|
||||
*/
|
||||
app.get("/metrics/history/:id", validateHostId, async (req, res) => {
|
||||
const hostId = Number(req.params.id);
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const hasAccess = await canAccessHost(userId, hostId, "connect");
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
const { range, from, to } = req.query as Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
let fromTs: string;
|
||||
let toTs: string = new Date().toISOString();
|
||||
|
||||
if (range) {
|
||||
const offsetMs = RANGE_OFFSETS[range];
|
||||
if (!offsetMs) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
|
||||
}
|
||||
fromTs = new Date(Date.now() - offsetMs).toISOString();
|
||||
} else if (from && to) {
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid from/to date format" });
|
||||
}
|
||||
fromTs = fromDate.toISOString();
|
||||
toTs = toDate.toISOString();
|
||||
} else {
|
||||
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP stores 'YYYY-MM-DD HH:MM:SS' (no T/Z).
|
||||
// Normalize both sides to that format for reliable string comparison.
|
||||
const toSqlite = (iso: string) =>
|
||||
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
const rows = (
|
||||
await createCurrentHostMetricsHistoryRepository().listRange(
|
||||
hostId,
|
||||
toSqlite(fromTs),
|
||||
toSqlite(toTs),
|
||||
)
|
||||
).map((row) => ({
|
||||
ts: row.ts,
|
||||
cpu_percent: row.cpuPercent,
|
||||
mem_percent: row.memPercent,
|
||||
disk_percent: row.diskPercent,
|
||||
net_rx_bytes: row.netRxBytes,
|
||||
net_tx_bytes: row.netTxBytes,
|
||||
}));
|
||||
|
||||
res.json({ rows, fromTs, toTs });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch metrics history", {
|
||||
operation: "metrics_history_fetch_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch metrics history" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
|
||||
/**
|
||||
* Registered as ctx.registry.provide("host-metrics.hostImportNormalizer", ...)
|
||||
* for core's bulk host import. A Termix export carries these settings under
|
||||
* pluginSettings["host-metrics"]; exports from before 2.9.0 carried them in
|
||||
* statsConfig, where the host's own interval only counted when
|
||||
* useGlobalMetricsInterval was false.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const bag = raw.pluginSettings as
|
||||
Record<string, Record<string, unknown>> | undefined;
|
||||
const own = bag?.["host-metrics"];
|
||||
if (own && typeof own === "object") {
|
||||
const settings = readHostMetricsSettings(own);
|
||||
return { ...settings };
|
||||
}
|
||||
|
||||
const legacy =
|
||||
raw.statsConfig && typeof raw.statsConfig === "object"
|
||||
? (raw.statsConfig as Record<string, unknown>)
|
||||
: null;
|
||||
if (!legacy) return null;
|
||||
const settings = readHostMetricsSettings({
|
||||
...legacy,
|
||||
metricsInterval:
|
||||
legacy.useGlobalMetricsInterval === false ? legacy.metricsInterval : null,
|
||||
});
|
||||
return { ...settings };
|
||||
}
|
||||
@@ -1,34 +1,108 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import {
|
||||
onHostDeleted,
|
||||
onHostLogin,
|
||||
onHostUpdated,
|
||||
shutdown,
|
||||
startHostMetricsService,
|
||||
} from "./routes.js";
|
||||
import { createLogger } from "./log.js";
|
||||
import { createHostMetricsRepository } from "./repository.js";
|
||||
import { createMetricsState } from "./state.js";
|
||||
import { MetricsSessions } from "./sessions.js";
|
||||
import { CollectorRegistry } from "./collectors.js";
|
||||
import { MetricsPoller, TOPIC_HEALTH_CHECK } from "./poller.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { newSessionId, supportsMetrics } from "./helpers.js";
|
||||
|
||||
export type { MetricsCollectorV1 } from "./collectors.js";
|
||||
export type { CollectionStatusPayload, SnapshotPayload } from "./poller.js";
|
||||
|
||||
/** The "host-metrics.viewers" service, version 1. Runs as the caller's user. */
|
||||
export interface MetricsViewersV1 {
|
||||
register: (
|
||||
hostId: number,
|
||||
) => Promise<{ viewerSessionId: string } | { skipped: true; reason: string }>;
|
||||
heartbeat: (viewerSessionId: string) => boolean;
|
||||
unregister: (hostId: number, viewerSessionId: string) => void;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
await startHostMetricsService(ctx.http.router());
|
||||
const log = createLogger(ctx.log);
|
||||
const repository = await createHostMetricsRepository(ctx.db);
|
||||
const state = createMetricsState();
|
||||
const sessions = new MetricsSessions(ctx.schedule, log);
|
||||
const collectors = new CollectorRegistry(ctx.services, log);
|
||||
const poller = new MetricsPoller({
|
||||
ctx,
|
||||
log,
|
||||
repository,
|
||||
ssh: ctx.ssh,
|
||||
state,
|
||||
sessions,
|
||||
collectors,
|
||||
});
|
||||
ctx.disposables.add(() => {
|
||||
poller.dispose();
|
||||
sessions.dispose();
|
||||
});
|
||||
|
||||
registerRoutes(ctx.http.router<Router>(), {
|
||||
ctx,
|
||||
log,
|
||||
poller,
|
||||
sessions,
|
||||
repository,
|
||||
onHealthCheck: (event) => ctx.events.emit(TOPIC_HEALTH_CHECK, event),
|
||||
});
|
||||
|
||||
ctx.schedule.every(60_000, () => poller.cleanupInactiveViewers());
|
||||
ctx.schedule.every(10 * 60_000, () => {
|
||||
state.authFailures.cleanup();
|
||||
state.backoff.cleanup();
|
||||
});
|
||||
|
||||
// Core used to POST these to this plugin's own port with an internal auth
|
||||
// token. They are events now: no port, no shared secret, and the
|
||||
// subscriptions are torn down with the plugin.
|
||||
ctx.events.on("host.updated", (payload) => {
|
||||
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) void poller.hostUpdated(hostId);
|
||||
});
|
||||
ctx.events.on("host.deleted", (payload) => {
|
||||
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) poller.hostDeleted(hostId);
|
||||
});
|
||||
ctx.events.on("host.login", (payload) => {
|
||||
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
|
||||
ctx.events.on("host.status", (payload) => {
|
||||
const { hostId, status } = payload as { hostId?: number; status?: string };
|
||||
if (hostId && status) poller.hostStatusChanged(hostId, status);
|
||||
});
|
||||
ctx.events.on("host.key.updated", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) poller.hostKeyAccepted(hostId);
|
||||
});
|
||||
ctx.settings.onChange("metricsInterval", () => void poller.retimeAll());
|
||||
|
||||
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
|
||||
register: async (hostId) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return { skipped: true, reason: "no_user" };
|
||||
const host = await poller.resolve(hostId, userId);
|
||||
if (!host) return { skipped: true, reason: "host_not_found" };
|
||||
if (!supportsMetrics(host, ctx.ssh)) {
|
||||
return { skipped: true, reason: "metrics_unsupported" };
|
||||
}
|
||||
if (!(await poller.settingsFor(hostId)).metricsEnabled) {
|
||||
return { skipped: true, reason: "metrics_disabled" };
|
||||
}
|
||||
const viewerSessionId = newSessionId("service");
|
||||
poller.registerViewer(hostId, viewerSessionId, userId);
|
||||
return { viewerSessionId };
|
||||
},
|
||||
heartbeat: (viewerSessionId) =>
|
||||
poller.updateHeartbeat(viewerSessionId, ctx.currentActor()),
|
||||
unregister: (hostId, viewerSessionId) =>
|
||||
poller.unregisterViewer(hostId, viewerSessionId, ctx.currentActor()),
|
||||
});
|
||||
|
||||
ctx.registry.provide(
|
||||
"host-metrics.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
|
||||
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
shutdown();
|
||||
}
|
||||
export async function deactivate() {}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { Client, type ConnectConfig } from "ssh2";
|
||||
import type { PluginSsh } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
connectionLog,
|
||||
newSessionId,
|
||||
type ConnectionLog,
|
||||
type MetricsHost,
|
||||
} from "./helpers.js";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
import { sessionKey, type MetricsSessions } from "./sessions.js";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 60_000;
|
||||
const TOTP_TTL_MS = 180_000;
|
||||
const MAX_TOTP_ATTEMPTS = 3;
|
||||
|
||||
export type StartResult =
|
||||
| { status: 200; body: Record<string, unknown> }
|
||||
| { status: 400 | 401 | 500; body: Record<string, unknown> };
|
||||
|
||||
export interface InteractiveDeps {
|
||||
ssh: PluginSsh;
|
||||
sessions: MetricsSessions;
|
||||
log: MetricsLogger;
|
||||
registerViewer: (hostId: number, sessionId: string, userId: string) => void;
|
||||
}
|
||||
|
||||
function stageForError(message: string): [string, string] {
|
||||
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
|
||||
return ["dns", `DNS resolution failed: ${message}`];
|
||||
}
|
||||
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
|
||||
return ["tcp", `TCP connection failed: ${message}`];
|
||||
}
|
||||
if (message.includes("handshake") || message.includes("key exchange")) {
|
||||
return ["handshake", `SSH handshake failed: ${message}`];
|
||||
}
|
||||
if (/authentication/i.test(message)) {
|
||||
return ["auth", `Authentication failed: ${message}`];
|
||||
}
|
||||
if (message.includes("verification failed")) {
|
||||
return [
|
||||
"handshake",
|
||||
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
||||
];
|
||||
}
|
||||
return ["error", `SSH connection failed: ${message}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection from the tab, where a person can answer a TOTP prompt,
|
||||
* and keeps it for polling. Resolves with the HTTP answer.
|
||||
*/
|
||||
export async function startInteractive(
|
||||
deps: InteractiveDeps,
|
||||
host: MetricsHost,
|
||||
userId: string,
|
||||
): Promise<StartResult> {
|
||||
const { ssh, sessions, log } = deps;
|
||||
const logs: ConnectionLog[] = [
|
||||
connectionLog("info", "stats_connecting", "Starting metrics collection"),
|
||||
connectionLog("info", "dns", `Resolving DNS for ${host.ip}`),
|
||||
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
|
||||
connectionLog("info", "handshake", "Initiating SSH handshake"),
|
||||
];
|
||||
if (host.authType === "password") {
|
||||
logs.push(connectionLog("info", "auth", "Authenticating with password"));
|
||||
} else if (host.authType === "key") {
|
||||
logs.push(connectionLog("info", "auth", "Authenticating with SSH key"));
|
||||
}
|
||||
|
||||
const key = sessionKey(host.id, userId);
|
||||
if (sessions.get(key)?.isConnected) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"stats_polling",
|
||||
"Using existing metrics session",
|
||||
),
|
||||
);
|
||||
return { status: 200, body: { success: true, connectionLogs: logs } };
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
const prepared = await ssh.prepare(host, {
|
||||
purpose: "metrics",
|
||||
client,
|
||||
log: (level, message) => logs.push(connectionLog(level, "auth", message)),
|
||||
});
|
||||
const config = prepared.config as ConnectConfig;
|
||||
config.readyTimeout = CONNECT_TIMEOUT_MS;
|
||||
if (prepared.outcome.status !== "ready") {
|
||||
const outcome = prepared.outcome;
|
||||
logs.push(connectionLog("error", "auth", outcome.message));
|
||||
if (outcome.status === "interaction-required") {
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: outcome.message,
|
||||
requiresAuthInteraction: outcome.interaction,
|
||||
...(outcome.flag ? { [outcome.flag]: true } : {}),
|
||||
connectionLogs: logs,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: outcome.message, connectionLogs: logs },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await new Promise<Record<string, unknown>>(
|
||||
(resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
fn();
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
settle(() => {
|
||||
client.end();
|
||||
reject(new Error("Connection timeout"));
|
||||
}),
|
||||
CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(name, instructions, _lang, prompts, finish) => {
|
||||
const decision = ssh.classifyKeyboardInteractive(
|
||||
{ name, instructions, prompts },
|
||||
host,
|
||||
);
|
||||
if (decision.kind !== "totp") {
|
||||
finish(ssh.autoResponses(prompts, host.password));
|
||||
return;
|
||||
}
|
||||
const sessionId = newSessionId("totp");
|
||||
sessions.addPending({
|
||||
client,
|
||||
finish,
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
userId,
|
||||
prompts: prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo ?? false,
|
||||
})),
|
||||
totpPromptIndex: decision.promptIndex,
|
||||
resolvedPassword: host.password ?? undefined,
|
||||
totpAttempts: 0,
|
||||
});
|
||||
logs.push(
|
||||
connectionLog("info", "stats_totp", "TOTP verification required"),
|
||||
);
|
||||
settle(() =>
|
||||
resolve({
|
||||
success: false,
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: prompts[decision.promptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
client.on("ready", () =>
|
||||
settle(() => {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"connected",
|
||||
"SSH connection established successfully",
|
||||
),
|
||||
connectionLog(
|
||||
"success",
|
||||
"stats_polling",
|
||||
"Metrics session established",
|
||||
),
|
||||
);
|
||||
sessions.open(key, {
|
||||
client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: host.id,
|
||||
userId,
|
||||
});
|
||||
const viewerSessionId = newSessionId("viewer");
|
||||
deps.registerViewer(host.id, viewerSessionId, userId);
|
||||
resolve({ success: true, viewerSessionId });
|
||||
}),
|
||||
);
|
||||
|
||||
client.on("error", (error) =>
|
||||
settle(() => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
const [stage, text] = stageForError(message);
|
||||
logs.push(connectionLog("error", stage, text));
|
||||
log.error("SSH connection error in metrics/start", {
|
||||
operation: "metrics_start_ssh_error",
|
||||
hostId: host.id,
|
||||
error: message,
|
||||
});
|
||||
reject(error);
|
||||
}),
|
||||
);
|
||||
|
||||
if (Array.isArray(host.jumpHosts) && host.jumpHosts.length > 0) {
|
||||
logs.push(
|
||||
connectionLog("info", "proxy", "Connecting via jump host chain"),
|
||||
);
|
||||
}
|
||||
ssh
|
||||
.openTransport(host, config as Record<string, unknown>)
|
||||
.then((transport) => {
|
||||
if (transport.jumpClient) {
|
||||
const jumpClient = transport.jumpClient as Client;
|
||||
client.on("close", () => jumpClient.end());
|
||||
}
|
||||
client.connect(config);
|
||||
})
|
||||
.catch((error) =>
|
||||
settle(() => {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"proxy",
|
||||
`Connection setup failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
);
|
||||
reject(error);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
return { status: 200, body: { ...result, connectionLogs: logs } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"stats_connecting",
|
||||
`Failed to start metrics: ${message}`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
error: message || "Failed to start metrics collection",
|
||||
connectionLogs: logs,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type TotpResult = { status: number; body: Record<string, unknown> };
|
||||
|
||||
/** Sends the TOTP code a person typed and keeps the connection on success. */
|
||||
export async function submitTotp(
|
||||
deps: InteractiveDeps,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<TotpResult> {
|
||||
const { sessions, log } = deps;
|
||||
const session = sessions.getPending(sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: "TOTP session not found or expired" },
|
||||
};
|
||||
}
|
||||
if (Date.now() - session.createdAt > TOTP_TTL_MS) {
|
||||
sessions.dropPending(sessionId, true);
|
||||
return { status: 408, body: { error: "TOTP session timeout" } };
|
||||
}
|
||||
if (session.userId !== userId) {
|
||||
return { status: 403, body: { error: "Unauthorized" } };
|
||||
}
|
||||
|
||||
session.totpAttempts++;
|
||||
if (session.totpAttempts > MAX_TOTP_ATTEMPTS) {
|
||||
sessions.dropPending(sessionId, true);
|
||||
return { status: 429, body: { error: "Too many TOTP attempts" } };
|
||||
}
|
||||
|
||||
const passwordFor = (prompt: string) =>
|
||||
/password/i.test(prompt) && session.resolvedPassword
|
||||
? session.resolvedPassword
|
||||
: "";
|
||||
|
||||
try {
|
||||
const responses = (session.prompts || []).map((p, index) =>
|
||||
index === session.totpPromptIndex
|
||||
? totpCode.trim()
|
||||
: passwordFor(p.prompt),
|
||||
);
|
||||
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("TOTP verification timeout")),
|
||||
30_000,
|
||||
);
|
||||
session.client.once(
|
||||
"keyboard-interactive",
|
||||
(_name, _instructions, _lang, prompts, finish) => {
|
||||
log.warn("Second keyboard-interactive received after TOTP", {
|
||||
operation: "totp_second_keyboard_interactive",
|
||||
hostId: session.hostId,
|
||||
});
|
||||
finish(prompts.map((p) => passwordFor(p.prompt)));
|
||||
},
|
||||
);
|
||||
session.client.once("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
session.client.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
session.finish(responses);
|
||||
await ready;
|
||||
|
||||
sessions.open(sessionKey(session.hostId, userId), {
|
||||
client: session.client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
hostId: session.hostId,
|
||||
userId,
|
||||
});
|
||||
sessions.dropPending(sessionId);
|
||||
|
||||
const viewerSessionId = newSessionId("viewer");
|
||||
deps.registerViewer(session.hostId, viewerSessionId, userId);
|
||||
return { status: 200, body: { success: true, viewerSessionId } };
|
||||
} catch (error) {
|
||||
log.error("TOTP verification failed", {
|
||||
operation: "totp_verification_failed",
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (session.totpAttempts >= MAX_TOTP_ATTEMPTS) {
|
||||
sessions.dropPending(sessionId, true);
|
||||
}
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
error: "TOTP verification failed",
|
||||
attemptsRemaining: Math.max(
|
||||
0,
|
||||
MAX_TOTP_ATTEMPTS - session.totpAttempts,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface MetricsLogger {
|
||||
debug: (message: string, meta?: Meta) => void;
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
/** An error, or structured context, or both. */
|
||||
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
return meta && Object.keys(meta).length > 0
|
||||
? `${message} ${JSON.stringify(meta)}`
|
||||
: message;
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old stats logger carried. */
|
||||
export function createLogger(log: PluginLogger): MetricsLogger {
|
||||
return {
|
||||
debug: (message, meta) => log.debug(withMeta(message, meta)),
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, errorOrMeta, meta) => {
|
||||
if (errorOrMeta instanceof Error) {
|
||||
log.error(withMeta(message, meta), errorOrMeta);
|
||||
return;
|
||||
}
|
||||
log.error(
|
||||
withMeta(message, {
|
||||
...((errorOrMeta as Meta | undefined) ?? {}),
|
||||
...meta,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface CronEntry {
|
||||
@@ -78,14 +80,12 @@ export function buildApplyCrontabCommand(body: string): string {
|
||||
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
||||
}
|
||||
|
||||
export function registerCronRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
||||
managerHandler(deps, "connect", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
@@ -95,7 +95,7 @@ export function registerCronRoutes(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { Express } from "express";
|
||||
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidPort,
|
||||
isValidIpProtocol,
|
||||
isValidFirewallTarget,
|
||||
type IpProtocol,
|
||||
type FirewallTarget,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface FirewallRuleSpec {
|
||||
@@ -45,13 +44,14 @@ export function buildNftRuleCommand(
|
||||
}
|
||||
|
||||
export function registerFirewallRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
|
||||
managerHandler(deps, "connect", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export function registerFirewallRoutes(
|
||||
"/host-metrics/managers/firewall/:id/rule",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
@@ -105,7 +105,7 @@ export function registerFirewallRoutes(
|
||||
"/host-metrics/managers/firewall/:id/persist",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Express } from "express";
|
||||
import {
|
||||
execCommand,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidPort } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
|
||||
import type { HostMetricsRepository } from "../repository.js";
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
@@ -111,24 +111,12 @@ async function runChecks(
|
||||
|
||||
const HISTORY_KEEP = 500;
|
||||
|
||||
function recordHistory(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResult[],
|
||||
): Promise<void> {
|
||||
return createCurrentHostHealthRepository()
|
||||
.recordHistory(userId, hostId, results, HISTORY_KEEP)
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
async function loadChecks(
|
||||
repository: HostMetricsRepository,
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
const row = await repository.findChecks(userId, hostId);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
@@ -139,60 +127,50 @@ async function loadChecks(
|
||||
}
|
||||
|
||||
export function registerHealthRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, repository } = deps;
|
||||
|
||||
const record = async (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResult[],
|
||||
) => {
|
||||
if (!results.length) return;
|
||||
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
|
||||
for (const result of results) {
|
||||
deps.onHealthCheck({
|
||||
hostId,
|
||||
userId,
|
||||
checkId: result.checkId,
|
||||
ok: result.ok,
|
||||
detail: result.detail ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
app.get(
|
||||
"/host-metrics/managers/health/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_get",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = checks.length ? await runChecks(client, checks) : [];
|
||||
if (results.length) {
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const history = (
|
||||
await createCurrentHostHealthRepository().listHistory(
|
||||
userId,
|
||||
host.id,
|
||||
200,
|
||||
)
|
||||
).map((row) => ({
|
||||
checkId: row.checkId,
|
||||
ts: row.ts,
|
||||
ok: row.ok,
|
||||
latencyMs: row.latencyMs,
|
||||
detail: row.detail,
|
||||
}));
|
||||
return { checks, results, history };
|
||||
},
|
||||
),
|
||||
managerHandler(deps, "connect", "health_get", async (client, host) => {
|
||||
const userId = host.actorId;
|
||||
const checks = await loadChecks(repository, userId, host.id);
|
||||
const results = checks.length ? await runChecks(client, checks) : [];
|
||||
await record(userId, host.id, results);
|
||||
const history = await repository.listHealth(userId, host.id, 200);
|
||||
return { checks, results, history };
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/config",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const userId = host.actorId;
|
||||
const { checks, intervalSeconds } = req.body as {
|
||||
checks?: unknown;
|
||||
intervalSeconds?: number;
|
||||
@@ -206,13 +184,11 @@ export function registerHealthRoutes(
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
const now = new Date().toISOString();
|
||||
await createCurrentHostHealthRepository().upsertChecks(
|
||||
await repository.saveChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
now,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
@@ -222,28 +198,11 @@ export function registerHealthRoutes(
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/run",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_run",
|
||||
async (client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const checks = await loadChecks(userId, host.id);
|
||||
const results = await runChecks(client, checks);
|
||||
if (results.length) {
|
||||
await recordHistory(userId, host.id, results);
|
||||
for (const r of results) {
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
),
|
||||
managerHandler(deps, "connect", "health_run", async (client, host) => {
|
||||
const checks = await loadChecks(repository, host.actorId, host.id);
|
||||
const results = await runChecks(client, checks);
|
||||
await record(host.actorId, host.id, results);
|
||||
return { results };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Express } from "express";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { registerServiceRoutes } from "./services.js";
|
||||
@@ -19,10 +19,10 @@ import { registerWireGuardRoutes } from "./wireguard.js";
|
||||
* prefix on the stats app. All routes are on-demand (not polled).
|
||||
*/
|
||||
export function registerManagerRoutes(
|
||||
app: Express,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, runOnHost } = deps;
|
||||
const { validateHostId } = deps;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -44,7 +44,7 @@ export function registerManagerRoutes(
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
|
||||
managerHandler(deps, "connect", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isAllowedPath,
|
||||
isValidSystemdUnit,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
/** Directories from which arbitrary log files may be tailed. */
|
||||
@@ -44,14 +41,12 @@ export function buildJournalCommand(unit: string, lines: number): string {
|
||||
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
|
||||
}
|
||||
|
||||
export function registerLogRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
|
||||
managerHandler(deps, "connect", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
@@ -65,33 +60,28 @@ export function registerLogRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"logs_tail",
|
||||
async (client, host, req) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
const unit = req.query.unit as string | undefined;
|
||||
const lines = clampLines(req.query.lines);
|
||||
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
|
||||
const path = req.query.path as string | undefined;
|
||||
const unit = req.query.unit as string | undefined;
|
||||
const lines = clampLines(req.query.lines);
|
||||
|
||||
let cmd: string;
|
||||
if (unit) {
|
||||
if (!isValidSystemdUnit(unit))
|
||||
throw new ManagerInputError("Invalid unit");
|
||||
cmd = buildJournalCommand(unit, lines);
|
||||
} else if (path) {
|
||||
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
|
||||
throw new ManagerInputError("Path not allowed");
|
||||
}
|
||||
cmd = buildTailCommand(path, lines);
|
||||
} else {
|
||||
throw new ManagerInputError("Provide a path or unit");
|
||||
let cmd: string;
|
||||
if (unit) {
|
||||
if (!isValidSystemdUnit(unit))
|
||||
throw new ManagerInputError("Invalid unit");
|
||||
cmd = buildJournalCommand(unit, lines);
|
||||
} else if (path) {
|
||||
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
|
||||
throw new ManagerInputError("Path not allowed");
|
||||
}
|
||||
cmd = buildTailCommand(path, lines);
|
||||
} else {
|
||||
throw new ManagerInputError("Provide a path or unit");
|
||||
}
|
||||
|
||||
// Try unprivileged; many logs need root (auth.log, etc.).
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return { content: result.stdout, lines };
|
||||
},
|
||||
),
|
||||
// Try unprivileged; many logs need root (auth.log, etc.).
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return { content: result.stdout, lines };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
detectPlatform,
|
||||
buildListUpgradableCommand,
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
type PackageAction,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidPackageName } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export function registerPackageRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
|
||||
managerHandler(deps, "connect", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
@@ -35,7 +36,7 @@ export function registerPackageRoutes(
|
||||
"/host-metrics/managers/packages/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidPid,
|
||||
isValidSignal,
|
||||
type Signal,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface ProcessRow {
|
||||
@@ -54,9 +49,10 @@ export function buildKillCommand(pid: number, signal: Signal): string {
|
||||
}
|
||||
|
||||
export function registerProcessRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}:
|
||||
@@ -74,7 +70,7 @@ export function registerProcessRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
|
||||
managerHandler(deps, "connect", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
@@ -109,7 +105,7 @@ export function registerProcessRoutes(
|
||||
"/host-metrics/managers/processes/:id/signal",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
|
||||
import { ElevationError } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
|
||||
import { statsLogger } from "../../../../../src/backend/utils/logger.js";
|
||||
import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
|
||||
import { errorMessage } from "../util.js";
|
||||
import type { MetricsLogger } from "../log.js";
|
||||
import type { ManagerHost, RunOnHost } from "./types.js";
|
||||
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(message = "No access to this host") {
|
||||
@@ -26,16 +25,15 @@ export class ManagerInputError extends Error {
|
||||
* given access level, and maps known errors to clean HTTP responses.
|
||||
*/
|
||||
export function managerHandler(
|
||||
runOnHost: RunOnHost,
|
||||
level: HostAction,
|
||||
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
|
||||
level: PluginHostShareLevel,
|
||||
operation: string,
|
||||
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
|
||||
) {
|
||||
return async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
const result = await runOnHost(hostId, userId, level, (client, host) =>
|
||||
const result = await runOnHost(hostId, level, (client, host) =>
|
||||
fn(client, host, req),
|
||||
);
|
||||
return res.json(result);
|
||||
@@ -49,13 +47,13 @@ export function managerHandler(
|
||||
if (error instanceof ElevationError) {
|
||||
return res.status(403).json({ error: error.message, code: error.code });
|
||||
}
|
||||
statsLogger.error(`Manager operation failed: ${operation}`, {
|
||||
log.error(`Manager operation failed: ${operation}`, {
|
||||
operation,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: getErrorMessage(error, "Operation failed"),
|
||||
error: errorMessage(error, "Operation failed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidServiceAction,
|
||||
type ServiceAction,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemdService {
|
||||
@@ -50,9 +49,10 @@ export function buildServiceActionCommand(
|
||||
}
|
||||
|
||||
export function registerServiceRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}:
|
||||
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "services_list", async (client) => {
|
||||
managerHandler(deps, "connect", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
@@ -105,7 +105,7 @@ export function registerServiceRoutes(
|
||||
"/host-metrics/managers/services/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
@@ -106,13 +106,14 @@ export function parseDfMounts(output: string): MountUsage[] {
|
||||
}
|
||||
|
||||
export function registerSimpleReadRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
|
||||
managerHandler(deps, "connect", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
@@ -121,7 +122,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
|
||||
managerHandler(deps, "connect", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
@@ -130,7 +131,7 @@ export function registerSimpleReadRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
|
||||
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
detectPlatform,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isAllowedPath,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export type AcmeClient = "certbot" | "acme.sh";
|
||||
@@ -149,14 +149,12 @@ export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
||||
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
||||
}
|
||||
|
||||
export function registerSslRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
|
||||
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
@@ -188,78 +186,68 @@ export function registerSslRoutes(
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/issue",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"ssl_issue",
|
||||
async (client, host, req) => {
|
||||
const body = req.body as Partial<IssueRequest>;
|
||||
if (body.client !== "certbot" && body.client !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!Array.isArray(body.domains) || body.domains.length === 0) {
|
||||
throw new ManagerInputError("At least one domain is required");
|
||||
}
|
||||
for (const d of body.domains) {
|
||||
if (!isValidDomain(d))
|
||||
throw new ManagerInputError(`Invalid domain: ${d}`);
|
||||
}
|
||||
const challenge = body.challenge;
|
||||
if (
|
||||
challenge !== "http-standalone" &&
|
||||
challenge !== "http-webroot" &&
|
||||
challenge !== "dns"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid challenge type");
|
||||
}
|
||||
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
|
||||
throw new ManagerInputError("Invalid DNS provider");
|
||||
}
|
||||
if (
|
||||
challenge === "http-webroot" &&
|
||||
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
|
||||
) {
|
||||
throw new ManagerInputError("Invalid or disallowed webroot path");
|
||||
}
|
||||
const cmd = buildIssueCommand(body as IssueRequest);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
|
||||
const body = req.body as Partial<IssueRequest>;
|
||||
if (body.client !== "certbot" && body.client !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!Array.isArray(body.domains) || body.domains.length === 0) {
|
||||
throw new ManagerInputError("At least one domain is required");
|
||||
}
|
||||
for (const d of body.domains) {
|
||||
if (!isValidDomain(d))
|
||||
throw new ManagerInputError(`Invalid domain: ${d}`);
|
||||
}
|
||||
const challenge = body.challenge;
|
||||
if (
|
||||
challenge !== "http-standalone" &&
|
||||
challenge !== "http-webroot" &&
|
||||
challenge !== "dns"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid challenge type");
|
||||
}
|
||||
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
|
||||
throw new ManagerInputError("Invalid DNS provider");
|
||||
}
|
||||
if (
|
||||
challenge === "http-webroot" &&
|
||||
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
|
||||
) {
|
||||
throw new ManagerInputError("Invalid or disallowed webroot path");
|
||||
}
|
||||
const cmd = buildIssueCommand(body as IssueRequest);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/renew",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"ssl_renew",
|
||||
async (client, host, req) => {
|
||||
const { client: acmeClient, dryRun } = req.body as {
|
||||
client?: AcmeClient;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
const cmd = buildRenewCommand(acmeClient, !!dryRun);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
|
||||
const { client: acmeClient, dryRun } = req.body as {
|
||||
client?: AcmeClient;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
const cmd = buildRenewCommand(acmeClient, !!dryRun);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 300000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -290,31 +278,26 @@ export function registerSslRoutes(
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/revoke",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"ssl_revoke",
|
||||
async (client, host, req) => {
|
||||
const { client: acmeClient, name } = req.body as {
|
||||
client?: AcmeClient;
|
||||
name?: string;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!isValidCertName(name)) {
|
||||
throw new ManagerInputError("Invalid certificate name");
|
||||
}
|
||||
const cmd = buildRevokeCommand(acmeClient, name);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
|
||||
const { client: acmeClient, name } = req.body as {
|
||||
client?: AcmeClient;
|
||||
name?: string;
|
||||
};
|
||||
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
||||
throw new ManagerInputError("Invalid ACME client");
|
||||
}
|
||||
if (!isValidCertName(name)) {
|
||||
throw new ManagerInputError("Invalid certificate name");
|
||||
}
|
||||
const cmd = buildRevokeCommand(acmeClient, name);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
|
||||
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
|
||||
import type { HostMetricsRepository } from "../repository.js";
|
||||
import type { MetricsLogger } from "../log.js";
|
||||
|
||||
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
||||
export interface ManagerHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
/** The user making the request, who may not own the host. */
|
||||
actorId: string;
|
||||
sudoPassword?: string;
|
||||
enableDocker?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` against a pooled SSH connection for the host, after verifying the
|
||||
* user has at least `level` access. Resolves the host (with sudoPassword) so
|
||||
* managers can elevate. Rejects with an access error if not permitted.
|
||||
* acting user has at least `level` access. Resolves the host (with
|
||||
* sudoPassword) so managers can elevate. Rejects with an access error if not
|
||||
* permitted.
|
||||
*/
|
||||
export type RunOnHost = <T>(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
level: HostAction,
|
||||
level: PluginHostShareLevel,
|
||||
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export interface HealthCheckEvent {
|
||||
hostId: number;
|
||||
userId: string;
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ManagerRoutesDeps {
|
||||
validateHostId: RequestHandler;
|
||||
runOnHost: RunOnHost;
|
||||
log: MetricsLogger;
|
||||
repository: HostMetricsRepository;
|
||||
onHealthCheck: (event: HealthCheckEvent) => void;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import { isValidUsername, isValidGroupName } from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidUsername,
|
||||
isValidGroupName,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemUser {
|
||||
@@ -75,14 +71,12 @@ export function parseSudoers(output: string): string[] {
|
||||
|
||||
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
|
||||
|
||||
export function registerUserRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
||||
const { validateHostId } = deps;
|
||||
app.get(
|
||||
"/host-metrics/managers/users/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "users_list", async (client) => {
|
||||
managerHandler(deps, "connect", "users_list", async (client) => {
|
||||
const [passwd, groups, sudoers] = await Promise.all([
|
||||
execCommand(client, READ_USERS_CMD, 15000),
|
||||
execCommand(client, READ_GROUPS_CMD, 15000),
|
||||
@@ -100,7 +94,7 @@ export function registerUserRoutes(
|
||||
"/host-metrics/managers/users/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"users_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Strict allowlist validators for every dynamic value that reaches a shell
|
||||
* command. Managers MUST validate inputs through these before interpolation;
|
||||
* never pass raw user text to the shell.
|
||||
*/
|
||||
|
||||
const SYSTEMD_UNIT_RE =
|
||||
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
|
||||
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
|
||||
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
|
||||
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
|
||||
const DOMAIN_RE =
|
||||
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
|
||||
const PROVIDER_RE = /^[a-z0-9_]+$/;
|
||||
|
||||
export function isValidSystemdUnit(unit: unknown): unit is string {
|
||||
return (
|
||||
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidPid(pid: unknown): pid is number {
|
||||
const n = typeof pid === "string" ? Number(pid) : pid;
|
||||
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
|
||||
}
|
||||
|
||||
export function isValidPort(port: unknown): port is number {
|
||||
const n = typeof port === "string" ? Number(port) : port;
|
||||
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
}
|
||||
|
||||
export function isValidPackageName(pkg: unknown): pkg is string {
|
||||
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
|
||||
}
|
||||
|
||||
export function isValidUsername(name: unknown): name is string {
|
||||
return (
|
||||
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidGroupName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
|
||||
}
|
||||
|
||||
export function isValidDomain(domain: unknown): domain is string {
|
||||
return (
|
||||
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidDnsProvider(provider: unknown): provider is string {
|
||||
return (
|
||||
typeof provider === "string" &&
|
||||
provider.length <= 64 &&
|
||||
PROVIDER_RE.test(provider)
|
||||
);
|
||||
}
|
||||
|
||||
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
|
||||
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
|
||||
export function isValidSignal(sig: unknown): sig is Signal {
|
||||
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
|
||||
}
|
||||
|
||||
export type ServiceAction =
|
||||
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
|
||||
const SERVICE_ACTIONS: ServiceAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"reload",
|
||||
"enable",
|
||||
"disable",
|
||||
];
|
||||
export function isValidServiceAction(a: unknown): a is ServiceAction {
|
||||
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
export type IpProtocol = "tcp" | "udp";
|
||||
export function isValidIpProtocol(p: unknown): p is IpProtocol {
|
||||
return p === "tcp" || p === "udp";
|
||||
}
|
||||
|
||||
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
|
||||
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
|
||||
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
|
||||
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
|
||||
}
|
||||
|
||||
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
|
||||
export function isValidWireGuardInterface(name: unknown): name is string {
|
||||
return typeof name === "string" && WG_IFACE_RE.test(name);
|
||||
}
|
||||
|
||||
export type WireGuardAction = "up" | "down";
|
||||
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
|
||||
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
|
||||
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
export type TailscaleAction = "up" | "down";
|
||||
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
|
||||
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
|
||||
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an absolute file path against an allowlist of permitted prefixes and
|
||||
* reject traversal. Used by the log viewer (e.g. only under /var/log).
|
||||
*/
|
||||
export function isAllowedPath(
|
||||
path: unknown,
|
||||
allowedPrefixes: string[],
|
||||
): path is string {
|
||||
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
|
||||
return false;
|
||||
}
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (path.includes("\0")) return false;
|
||||
if (path.split("/").some((seg) => seg === "..")) return false;
|
||||
return allowedPrefixes.some(
|
||||
(prefix) =>
|
||||
path === prefix ||
|
||||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import {
|
||||
isValidWireGuardInterface,
|
||||
isValidWireGuardAction,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
} from "./validation.js";
|
||||
import type { Router } from "express";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface WireGuardPeer {
|
||||
publicKey: string;
|
||||
@@ -117,9 +117,10 @@ export function parseWireGuardData(output: string): WireGuardData {
|
||||
}
|
||||
|
||||
export function registerWireGuardRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
app: Router,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId } = deps;
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}:
|
||||
@@ -140,23 +141,13 @@ export function registerWireGuardRoutes(
|
||||
app.get(
|
||||
"/host-metrics/managers/wireguard/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"wireguard_read",
|
||||
async (client, host) => {
|
||||
const result = await execElevated(
|
||||
client,
|
||||
PROBE_CMD,
|
||||
host.sudoPassword,
|
||||
{
|
||||
forceSudo: false,
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
);
|
||||
return parseWireGuardData(result.stdout);
|
||||
},
|
||||
),
|
||||
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
|
||||
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
|
||||
forceSudo: false,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
return parseWireGuardData(result.stdout);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -192,7 +183,7 @@ export function registerWireGuardRoutes(
|
||||
"/host-metrics/managers/wireguard/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
deps,
|
||||
"connect",
|
||||
"wireguard_action",
|
||||
async (client, host, req) => {
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginHostStatusEntry,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { readHostMetricsSettings } from "../shared/stats-widgets.js";
|
||||
import {
|
||||
collectMetrics,
|
||||
isHostKeyVerificationError,
|
||||
type CollectDeps,
|
||||
type CollectedMetrics,
|
||||
} from "./collect.js";
|
||||
import { supportsMetrics, type MetricsHost } from "./helpers.js";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
import type { HostMetricsRepository } from "./repository.js";
|
||||
import type { MetricsViewer } from "./sessions.js";
|
||||
import {
|
||||
canStartInitialMetrics,
|
||||
metricsConcurrencyFor,
|
||||
type MetricsState,
|
||||
} from "./state.js";
|
||||
import { clearCpuSampleCache } from "./widgets/cpu-collector.js";
|
||||
import { clearNetworkSampleCache } from "./widgets/network-collector.js";
|
||||
|
||||
export const DEFAULT_METRICS_INTERVAL = 30;
|
||||
export const DEFAULT_RETENTION_DAYS = 7;
|
||||
/** Viewers that stop sending heartbeats are dropped after this long. */
|
||||
const VIEWER_TIMEOUT_MS = 120_000;
|
||||
|
||||
export const TOPIC_SNAPSHOT = "plugin.host-metrics.snapshot";
|
||||
export const TOPIC_STATUS = "plugin.host-metrics.status";
|
||||
export const TOPIC_HEALTH_CHECK = "plugin.host-metrics.health-check";
|
||||
|
||||
/** Where collection stands for a host, sent when it changes. */
|
||||
export type CollectionState =
|
||||
"collecting" | "auth_failed" | "host_key_changed" | "unreachable";
|
||||
|
||||
export interface SnapshotPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
metrics: CollectedMetrics;
|
||||
}
|
||||
|
||||
export interface CollectionStatusPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
state: CollectionState;
|
||||
previous: CollectionState | null;
|
||||
}
|
||||
|
||||
interface Polled {
|
||||
host: MetricsHost;
|
||||
viewerUserId: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export interface PollerDeps extends CollectDeps {
|
||||
ctx: PluginContext;
|
||||
log: MetricsLogger;
|
||||
repository: HostMetricsRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heavy SSH metrics for hosts somebody is looking at. Polling starts with the
|
||||
* first viewer and stops with the last one; a viewer is a tab, a dashboard
|
||||
* card, or another plugin through the "host-metrics.viewers" service.
|
||||
*/
|
||||
export class MetricsPoller {
|
||||
private readonly polled = new Map<number, Polled>();
|
||||
private readonly metrics = new Map<
|
||||
number,
|
||||
{ data: CollectedMetrics; timestamp: number }
|
||||
>();
|
||||
private readonly viewers = new Map<number, Set<string>>();
|
||||
private readonly viewerDetails = new Map<string, MetricsViewer>();
|
||||
private readonly inFlight = new Set<number>();
|
||||
private readonly initialRequested = new Set<number>();
|
||||
private readonly collectionState = new Map<number, CollectionState>();
|
||||
|
||||
constructor(private readonly deps: PollerDeps) {}
|
||||
|
||||
private get ctx() {
|
||||
return this.deps.ctx;
|
||||
}
|
||||
|
||||
private get state(): MetricsState {
|
||||
return this.deps.state;
|
||||
}
|
||||
|
||||
getMetrics(hostId: number) {
|
||||
return this.metrics.get(hostId);
|
||||
}
|
||||
|
||||
isPolling(hostId: number): boolean {
|
||||
return this.polled.has(hostId);
|
||||
}
|
||||
|
||||
hasViewers(hostId: number): boolean {
|
||||
return (this.viewers.get(hostId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
registerViewer(hostId: number, sessionId: string, userId: string): void {
|
||||
let set = this.viewers.get(hostId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.viewers.set(hostId, set);
|
||||
}
|
||||
set.add(sessionId);
|
||||
this.viewerDetails.set(sessionId, {
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
if (set.size === 1) {
|
||||
// Never let a failed start reach the request that registered it.
|
||||
void this.start(hostId, userId).catch((error) => {
|
||||
this.deps.log.warn("Could not start metrics polling", {
|
||||
operation: "start_metrics_unhandled",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** False when the viewer is gone, so the caller registers again. */
|
||||
updateHeartbeat(sessionId: string, userId?: string): boolean {
|
||||
const viewer = this.viewerDetails.get(sessionId);
|
||||
if (!viewer || (userId && viewer.userId !== userId)) return false;
|
||||
viewer.lastHeartbeat = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
unregisterViewer(hostId: number, sessionId: string, userId?: string): void {
|
||||
const viewer = this.viewerDetails.get(sessionId);
|
||||
if (viewer && userId && viewer.userId !== userId) return;
|
||||
const set = this.viewers.get(hostId);
|
||||
if (set) {
|
||||
set.delete(sessionId);
|
||||
if (set.size === 0) {
|
||||
this.viewers.delete(hostId);
|
||||
this.stop(hostId, false);
|
||||
}
|
||||
}
|
||||
this.viewerDetails.delete(sessionId);
|
||||
}
|
||||
|
||||
/** Stops a host's polling without dropping its viewers. */
|
||||
pause(hostId: number): void {
|
||||
this.stop(hostId, false);
|
||||
}
|
||||
|
||||
cleanupInactiveViewers(now = Date.now()): void {
|
||||
for (const [sessionId, viewer] of [...this.viewerDetails.entries()]) {
|
||||
if (now - viewer.lastHeartbeat > VIEWER_TIMEOUT_MS) {
|
||||
this.unregisterViewer(viewer.hostId, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A host's details changed: drop what was cached and start over. */
|
||||
async hostUpdated(hostId: number): Promise<void> {
|
||||
this.state.hostCache.invalidate(hostId);
|
||||
this.state.metricsCache.clear(hostId);
|
||||
this.ctx.ssh.dropPooled("stats", hostId);
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled) await this.start(hostId, polled.viewerUserId);
|
||||
}
|
||||
|
||||
hostDeleted(hostId: number): void {
|
||||
this.stop(hostId, true);
|
||||
for (const sessionId of this.viewers.get(hostId) ?? []) {
|
||||
this.viewerDetails.delete(sessionId);
|
||||
}
|
||||
this.viewers.delete(hostId);
|
||||
this.collectionState.delete(hostId);
|
||||
this.state.authFailures.reset(hostId);
|
||||
this.state.backoff.reset(hostId);
|
||||
}
|
||||
|
||||
/** Core's status check says the host answers again. */
|
||||
hostStatusChanged(hostId: number, status: string): void {
|
||||
if (status === "offline") return;
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled) this.scheduleInitialPoll(polled.host);
|
||||
}
|
||||
|
||||
hostKeyAccepted(hostId: number): void {
|
||||
this.state.authFailures.resetHostKeyFailure(hostId);
|
||||
}
|
||||
|
||||
/** Re-times every running host, after the admin interval changed. */
|
||||
async retimeAll(): Promise<void> {
|
||||
for (const [hostId, polled] of [...this.polled.entries()]) {
|
||||
await this.start(hostId, polled.viewerUserId);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const hostId of [...this.polled.keys()]) this.stop(hostId, true);
|
||||
this.viewers.clear();
|
||||
this.viewerDetails.clear();
|
||||
clearCpuSampleCache();
|
||||
clearNetworkSampleCache();
|
||||
}
|
||||
|
||||
/** The host as `userId` may reach it, cached for a few minutes. */
|
||||
async resolve(hostId: number, userId: string): Promise<MetricsHost | null> {
|
||||
const cached = this.state.hostCache.get(
|
||||
hostId,
|
||||
userId,
|
||||
) as MetricsHost | null;
|
||||
if (cached) return cached;
|
||||
const host = (await this.ctx.asUser(userId, () =>
|
||||
this.ctx.ssh.resolveHost(hostId),
|
||||
)) as MetricsHost | null;
|
||||
if (!host) return null;
|
||||
this.state.hostCache.set(hostId, userId, host);
|
||||
return host;
|
||||
}
|
||||
|
||||
async settingsFor(hostId: number) {
|
||||
const settings = readHostMetricsSettings(
|
||||
await this.ctx.settings.getAll("host", hostId),
|
||||
);
|
||||
const adminInterval = Number(
|
||||
await this.ctx.settings.get<number>("metricsInterval"),
|
||||
);
|
||||
return {
|
||||
...settings,
|
||||
intervalSeconds:
|
||||
settings.metricsInterval ??
|
||||
(Number.isInteger(adminInterval) && adminInterval >= 5
|
||||
? adminInterval
|
||||
: DEFAULT_METRICS_INTERVAL),
|
||||
};
|
||||
}
|
||||
|
||||
private async start(hostId: number, userId: string): Promise<void> {
|
||||
const host = await this.resolve(hostId, userId);
|
||||
this.stop(hostId, false);
|
||||
if (!host || !supportsMetrics(host, this.ctx.ssh)) return;
|
||||
|
||||
const settings = await this.settingsFor(hostId);
|
||||
if (!settings.metricsEnabled) {
|
||||
this.metrics.delete(hostId);
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = settings.intervalSeconds * 1000;
|
||||
const stop = this.ctx.schedule.every(
|
||||
intervalMs,
|
||||
() => {
|
||||
const latest = this.polled.get(hostId);
|
||||
if (latest) this.schedulePoll(latest.host);
|
||||
},
|
||||
// Spread timers so a fleet does not fire on the same second.
|
||||
{ jitterMs: Math.min(intervalMs * 0.2, 15_000) },
|
||||
);
|
||||
this.polled.set(hostId, { host, viewerUserId: userId, stop });
|
||||
this.syncConcurrency();
|
||||
this.scheduleInitialPoll(host);
|
||||
}
|
||||
|
||||
private stop(hostId: number, clearData: boolean): void {
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled) {
|
||||
polled.stop();
|
||||
this.polled.delete(hostId);
|
||||
this.syncConcurrency();
|
||||
}
|
||||
this.inFlight.delete(hostId);
|
||||
if (clearData) {
|
||||
this.metrics.delete(hostId);
|
||||
this.state.hostCache.invalidate(hostId);
|
||||
clearCpuSampleCache(hostId);
|
||||
clearNetworkSampleCache(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps poll concurrency matched to how many hosts are being polled. */
|
||||
private syncConcurrency(): void {
|
||||
const target = metricsConcurrencyFor(this.polled.size);
|
||||
const limiter = this.state.metricsLimiter;
|
||||
if (target === limiter.limit) return;
|
||||
const previous = limiter.limit;
|
||||
limiter.setLimit(target);
|
||||
this.deps.log.info(
|
||||
`Metrics poll concurrency ${previous} -> ${target} for ${this.polled.size} host(s)`,
|
||||
{ operation: "metrics_concurrency_resize" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first heavy sample waits for core's cheap status check to say the
|
||||
* host answers, so a whole fleet registering at once does not open a
|
||||
* burst of SSH connections to hosts that are down.
|
||||
*/
|
||||
private scheduleInitialPoll(host: MetricsHost): void {
|
||||
if (this.metrics.has(host.id) || this.initialRequested.has(host.id)) {
|
||||
return;
|
||||
}
|
||||
this.initialRequested.add(host.id);
|
||||
void this.state.initialLimiter
|
||||
.run(async () => {
|
||||
let status: PluginHostStatusEntry | null = this.ctx.hosts.status.get(
|
||||
host.id,
|
||||
);
|
||||
if (!status) status = await this.ctx.hosts.status.check(host.id);
|
||||
if (!canStartInitialMetrics(status?.status, this.hasViewers(host.id))) {
|
||||
return;
|
||||
}
|
||||
if (this.inFlight.has(host.id)) return;
|
||||
this.inFlight.add(host.id);
|
||||
try {
|
||||
await this.state.metricsLimiter.run(() => this.poll(host));
|
||||
} finally {
|
||||
this.inFlight.delete(host.id);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.deps.log.error("Initial metrics polling failed", error, {
|
||||
operation: "initial_metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
})
|
||||
.finally(() => this.initialRequested.delete(host.id));
|
||||
}
|
||||
|
||||
private schedulePoll(host: MetricsHost): void {
|
||||
if (this.inFlight.has(host.id)) return;
|
||||
this.inFlight.add(host.id);
|
||||
void this.state.metricsLimiter
|
||||
.run(() => this.poll(host))
|
||||
.catch((error) => {
|
||||
this.deps.log.error("Metrics polling failed", error, {
|
||||
operation: "metrics_poll_unhandled",
|
||||
hostId: host.id,
|
||||
});
|
||||
})
|
||||
.finally(() => this.inFlight.delete(host.id));
|
||||
}
|
||||
|
||||
/** One sample for one host. Public for tests. */
|
||||
async poll(host: MetricsHost): Promise<void> {
|
||||
const { state, log } = this.deps;
|
||||
if (!this.polled.has(host.id)) return;
|
||||
if (state.authFailures.shouldSkip(host.id)) return;
|
||||
if (state.backoff.shouldSkip(host.id)) return;
|
||||
|
||||
const settings = await this.settingsFor(host.id);
|
||||
if (!settings.metricsEnabled) return;
|
||||
|
||||
let authenticated = false;
|
||||
try {
|
||||
const metrics = await collectMetrics(this.deps, host, settings, () => {
|
||||
authenticated = true;
|
||||
});
|
||||
this.metrics.set(host.id, { data: metrics, timestamp: Date.now() });
|
||||
this.ctx.hosts.status.reportLogin(host.id, { ok: true });
|
||||
this.setCollectionState(host, "collecting");
|
||||
await this.recordSample(host.id, metrics);
|
||||
this.ctx.events.emit(TOPIC_SNAPSHOT, {
|
||||
hostId: host.id,
|
||||
ownerUserId: host.userId,
|
||||
metrics,
|
||||
} satisfies SnapshotPayload);
|
||||
state.backoff.reset(host.id);
|
||||
state.authFailures.reset(host.id);
|
||||
} catch (error) {
|
||||
const hostKeyChanged = isHostKeyVerificationError(error);
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const isAuthError = /authentication|permission denied/i.test(message);
|
||||
|
||||
if (!authenticated) {
|
||||
this.ctx.hosts.status.reportLogin(host.id, {
|
||||
ok: false,
|
||||
hostKeyChanged,
|
||||
});
|
||||
}
|
||||
this.setCollectionState(
|
||||
host,
|
||||
hostKeyChanged
|
||||
? "host_key_changed"
|
||||
: isAuthError
|
||||
? "auth_failed"
|
||||
: "unreachable",
|
||||
);
|
||||
|
||||
if (isAuthError) {
|
||||
// collectMetrics already recorded it; log the first occurrence only.
|
||||
if (!state.authFailures.shouldSkip(host.id)) {
|
||||
log.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostKeyChanged) {
|
||||
state.authFailures.recordFailure(host.id, "HOST_KEY", true);
|
||||
log.error("Stats collector host key verification failed", error, {
|
||||
operation: "stats_host_key_verification_failed",
|
||||
hostId: host.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state.backoff.recordFailure(host.id);
|
||||
// Only log when a new retry window opens, not on every skipped poll.
|
||||
const backoff = state.backoff.getBackoffInfo(host.id);
|
||||
if (backoff !== null && !backoff.includes("polling suspended")) {
|
||||
log.error("Stats collector connection failed", error, {
|
||||
operation: "stats_connect_failed",
|
||||
hostId: host.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setCollectionState(host: MetricsHost, next: CollectionState): void {
|
||||
const previous = this.collectionState.get(host.id) ?? null;
|
||||
if (previous === next) return;
|
||||
this.collectionState.set(host.id, next);
|
||||
this.ctx.events.emit(TOPIC_STATUS, {
|
||||
hostId: host.id,
|
||||
ownerUserId: host.userId,
|
||||
state: next,
|
||||
previous,
|
||||
} satisfies CollectionStatusPayload);
|
||||
}
|
||||
|
||||
private async recordSample(
|
||||
hostId: number,
|
||||
metrics: CollectedMetrics,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const iface = metrics.network?.interfaces?.[0];
|
||||
const bytes = (value: string | null | undefined) => {
|
||||
const parsed = value ? parseInt(value, 10) : NaN;
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
const retention = Number(
|
||||
await this.ctx.settings.get<number>("historyRetentionDays"),
|
||||
);
|
||||
await this.deps.repository.recordSample(
|
||||
{
|
||||
hostId,
|
||||
cpuPercent: metrics.cpu?.percent ?? null,
|
||||
memPercent: metrics.memory?.percent ?? null,
|
||||
diskPercent: metrics.disk?.percent ?? null,
|
||||
netRxBytes: bytes(iface?.rxBytes),
|
||||
netTxBytes: bytes(iface?.txBytes),
|
||||
},
|
||||
Number.isInteger(retention) && retention >= 1
|
||||
? Math.min(retention, 90)
|
||||
: DEFAULT_RETENTION_DAYS,
|
||||
);
|
||||
} catch (error) {
|
||||
this.deps.log.warn("Failed to write metrics history", {
|
||||
operation: "insert_metrics_history",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { createCurrentHostMetricsPreferenceRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
deriveEnabledWidgets,
|
||||
defaultLayoutFromWidgets,
|
||||
type HostMetricsLayout,
|
||||
} from "../../../../src/types/host-metrics.js";
|
||||
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
interface PrefStatsConfig {
|
||||
enabledWidgets?: string[];
|
||||
}
|
||||
|
||||
interface PrefHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
statsConfig?: unknown;
|
||||
}
|
||||
|
||||
type HostMetricsPreferencesRoutesDeps = {
|
||||
validateHostId: RequestHandler;
|
||||
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
|
||||
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
|
||||
canAccessHost: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
level: HostAction,
|
||||
) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
if (!Array.isArray(obj.slots)) return null;
|
||||
const columns =
|
||||
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
|
||||
? Math.round(obj.columns)
|
||||
: 3;
|
||||
const slots = obj.slots
|
||||
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
|
||||
.map((s, i) => ({
|
||||
id: String(s.id),
|
||||
order: typeof s.order === "number" ? s.order : i,
|
||||
colSpan:
|
||||
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
|
||||
height:
|
||||
typeof s.height === "number" && s.height > 0
|
||||
? Math.round(s.height)
|
||||
: null,
|
||||
}))
|
||||
.filter((s) => s.id && s.id !== "undefined");
|
||||
return { slots, columns } as HostMetricsLayout;
|
||||
}
|
||||
|
||||
export function registerHostMetricsPreferencesRoutes(
|
||||
app: Express,
|
||||
{
|
||||
validateHostId,
|
||||
fetchHostById,
|
||||
parseStatsConfig,
|
||||
canAccessHost,
|
||||
}: HostMetricsPreferencesRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/preferences/{id}:
|
||||
* get:
|
||||
* summary: Get the Host Metrics layout for a host
|
||||
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The layout for this host.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
*/
|
||||
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const row =
|
||||
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
|
||||
if (row?.layout) {
|
||||
try {
|
||||
const parsed = sanitizeLayout(JSON.parse(row.layout));
|
||||
if (parsed) return res.json({ layout: parsed });
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
const statsConfig = parseStatsConfig(host.statsConfig);
|
||||
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
|
||||
return res.json({ layout });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch host metrics preferences", {
|
||||
operation: "host_metrics_prefs_fetch_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch host metrics preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/preferences/{id}:
|
||||
* post:
|
||||
* summary: Save the Host Metrics layout for a host
|
||||
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* slots:
|
||||
* type: array
|
||||
* columns:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Layout saved.
|
||||
* 400:
|
||||
* description: Invalid layout.
|
||||
* 403:
|
||||
* description: No access to this host.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/preferences/:id",
|
||||
validateHostId,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = parseInt(String(req.params.id), 10);
|
||||
try {
|
||||
if (!(await canAccessHost(userId, hostId, "connect"))) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
const host = await fetchHostById(hostId, userId);
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const layout = sanitizeLayout(req.body);
|
||||
if (!layout) {
|
||||
return res.status(400).json({ error: "Invalid layout" });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
|
||||
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
|
||||
userId,
|
||||
hostId,
|
||||
layoutJson,
|
||||
now,
|
||||
);
|
||||
|
||||
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
|
||||
// user owns. stats_config is plain JSON text (not an encrypted field).
|
||||
if (host.userId === userId) {
|
||||
try {
|
||||
const current = parseStatsConfig(host.statsConfig);
|
||||
const merged = {
|
||||
...current,
|
||||
enabledWidgets: deriveEnabledWidgets(layout.slots),
|
||||
};
|
||||
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
|
||||
userId,
|
||||
hostId,
|
||||
JSON.stringify(merged),
|
||||
);
|
||||
} catch (syncErr) {
|
||||
statsLogger.warn("Failed to sync enabledWidgets from layout", {
|
||||
operation: "host_metrics_prefs_sync_widgets",
|
||||
hostId,
|
||||
error:
|
||||
syncErr instanceof Error ? syncErr.message : String(syncErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save host metrics preferences", {
|
||||
operation: "host_metrics_prefs_save_error",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save host metrics preferences" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { and, asc, desc, eq, gte, lt, lte, notInArray } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
hostHealthChecks,
|
||||
hostHealthHistory,
|
||||
hostMetricsHistory,
|
||||
hostMetricsPreferences,
|
||||
} from "./tables.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands tables back untyped, and the drizzle handle is the
|
||||
// server's own. Typed at this module's edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface HealthResultInput {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface HealthHistoryRow {
|
||||
checkId: string;
|
||||
ts: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export interface MetricsHistoryInput {
|
||||
hostId: number;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskPercent: number | null;
|
||||
netRxBytes: number | null;
|
||||
netTxBytes: number | null;
|
||||
}
|
||||
|
||||
export interface MetricsHistoryRow {
|
||||
ts: string;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskPercent: number | null;
|
||||
netRxBytes: number | null;
|
||||
netTxBytes: number | null;
|
||||
}
|
||||
|
||||
/** Timestamps are text in `YYYY-MM-DD HH:MM:SS` UTC, which sorts in time order. */
|
||||
export function sqlTimestamp(date: Date): string {
|
||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
export type HostMetricsRepository = Awaited<
|
||||
ReturnType<typeof createHostMetricsRepository>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Written without RETURNING so it runs the same on all three engines: an
|
||||
* upsert reads the row by its unique (user, host) pair first.
|
||||
*/
|
||||
export async function createHostMetricsRepository(db: PluginDatabase) {
|
||||
const preferences: Table = await db.define(hostMetricsPreferences);
|
||||
const checks: Table = await db.define(hostHealthChecks);
|
||||
const health: Table = await db.define(hostHealthHistory);
|
||||
const history: Table = await db.define(hostMetricsHistory);
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function findOne(table: Table, userId: string, hostId: number) {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function upsert(
|
||||
table: Table,
|
||||
userId: string,
|
||||
hostId: number,
|
||||
values: Record<string, unknown>,
|
||||
) {
|
||||
const drizzle = await client();
|
||||
const now = sqlTimestamp(new Date());
|
||||
const existing = await findOne(table, userId, hostId);
|
||||
if (existing) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ ...values, updatedAt: now })
|
||||
.where(eq(table.id, existing.id));
|
||||
} else {
|
||||
await drizzle.insert(table).values({
|
||||
...values,
|
||||
userId,
|
||||
hostId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
async findLayout(userId: string, hostId: number): Promise<string | null> {
|
||||
return (await findOne(preferences, userId, hostId))?.layout ?? null;
|
||||
},
|
||||
|
||||
saveLayout(userId: string, hostId: number, layout: string) {
|
||||
return upsert(preferences, userId, hostId, { layout });
|
||||
},
|
||||
|
||||
async findChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<{ checks: string; intervalSeconds: number } | null> {
|
||||
return findOne(checks, userId, hostId);
|
||||
},
|
||||
|
||||
saveChecks(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
json: string,
|
||||
intervalSeconds: number,
|
||||
) {
|
||||
return upsert(checks, userId, hostId, { checks: json, intervalSeconds });
|
||||
},
|
||||
|
||||
/** Adds results and keeps only the newest `keep` rows for the host. */
|
||||
async recordHealth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
results: HealthResultInput[],
|
||||
keep: number,
|
||||
): Promise<void> {
|
||||
if (results.length === 0) return;
|
||||
const drizzle = await client();
|
||||
const ts = sqlTimestamp(new Date());
|
||||
await drizzle
|
||||
.insert(health)
|
||||
.values(results.map((result) => ({ ...result, userId, hostId, ts })));
|
||||
const scope = and(eq(health.userId, userId), eq(health.hostId, hostId));
|
||||
const retained = await drizzle
|
||||
.select({ id: health.id })
|
||||
.from(health)
|
||||
.where(scope)
|
||||
.orderBy(desc(health.ts), desc(health.id))
|
||||
.limit(keep);
|
||||
await drizzle.delete(health).where(
|
||||
retained.length
|
||||
? and(
|
||||
scope,
|
||||
notInArray(
|
||||
health.id,
|
||||
retained.map((row: { id: number }) => row.id),
|
||||
),
|
||||
)
|
||||
: scope,
|
||||
);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listHealth(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
limit: number,
|
||||
): Promise<HealthHistoryRow[]> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(health)
|
||||
.where(and(eq(health.userId, userId), eq(health.hostId, hostId)))
|
||||
.orderBy(desc(health.ts), desc(health.id))
|
||||
.limit(limit);
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
checkId: row.checkId as string,
|
||||
ts: row.ts as string,
|
||||
ok: row.ok === true || row.ok === 1,
|
||||
latencyMs: (row.latencyMs as number | null) ?? null,
|
||||
detail: (row.detail as string | null) ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
/** One sample, then drops the host's samples older than the retention. */
|
||||
async recordSample(
|
||||
input: MetricsHistoryInput,
|
||||
retentionDays: number,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
const now = new Date();
|
||||
await drizzle.insert(history).values({ ...input, ts: sqlTimestamp(now) });
|
||||
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
|
||||
await drizzle
|
||||
.delete(history)
|
||||
.where(
|
||||
and(
|
||||
eq(history.hostId, input.hostId),
|
||||
lt(history.ts, sqlTimestamp(cutoff)),
|
||||
),
|
||||
);
|
||||
// A sample every few seconds per host: let the debounced save pick it up.
|
||||
await db.persist({ lazy: true });
|
||||
},
|
||||
|
||||
async listSamples(
|
||||
hostId: number,
|
||||
fromTs: string,
|
||||
toTs: string,
|
||||
): Promise<MetricsHistoryRow[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select({
|
||||
ts: history.ts,
|
||||
cpuPercent: history.cpuPercent,
|
||||
memPercent: history.memPercent,
|
||||
diskPercent: history.diskPercent,
|
||||
netRxBytes: history.netRxBytes,
|
||||
netTxBytes: history.netTxBytes,
|
||||
})
|
||||
.from(history)
|
||||
.where(
|
||||
and(
|
||||
eq(history.hostId, hostId),
|
||||
gte(history.ts, fromTs),
|
||||
lte(history.ts, toTs),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(history.ts));
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,16 +1,19 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||
import type { MetricsLogger } from "./log.js";
|
||||
|
||||
/** A connection a person opened from the tab, reused by polling. */
|
||||
export interface MetricsSession {
|
||||
client: Client;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
cancelCleanup?: () => void;
|
||||
}
|
||||
|
||||
/** A connection waiting for the TOTP code the person is typing. */
|
||||
export interface PendingTOTPSession {
|
||||
client: Client;
|
||||
finish: (responses: string[]) => void;
|
||||
@@ -32,49 +35,106 @@ export interface MetricsViewer {
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
export const metricsSessions: Record<string, MetricsSession> = {};
|
||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
const IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
export function cleanupMetricsSession(sessionId: string): void {
|
||||
const session = metricsSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.activeOperations > 0) {
|
||||
statsLogger.warn(
|
||||
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
sessionId,
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
scheduleMetricsSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete metricsSessions[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleMetricsSessionCleanup(sessionId: string): void {
|
||||
const session = metricsSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
session.timeout = setTimeout(
|
||||
() => {
|
||||
cleanupMetricsSession(sessionId);
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionKey(hostId: number, userId: string): string {
|
||||
export function sessionKey(hostId: number, userId: string): string {
|
||||
return `${userId}:${hostId}`;
|
||||
}
|
||||
|
||||
function endQuietly(client: Client): void {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
/** Interactive connections, owned by one activation of the plugin. */
|
||||
export class MetricsSessions {
|
||||
private readonly sessions = new Map<string, MetricsSession>();
|
||||
private readonly pending = new Map<string, PendingTOTPSession>();
|
||||
|
||||
constructor(
|
||||
private readonly schedule: PluginSchedule,
|
||||
private readonly log: MetricsLogger,
|
||||
) {}
|
||||
|
||||
get(key: string): MetricsSession | undefined {
|
||||
return this.sessions.get(key);
|
||||
}
|
||||
|
||||
/** Stores a ready connection and closes it after 30 idle minutes. */
|
||||
open(key: string, session: Omit<MetricsSession, "cancelCleanup">): void {
|
||||
const existing = this.sessions.get(key);
|
||||
if (existing && existing.client !== session.client) this.close(key);
|
||||
this.sessions.set(key, { ...session });
|
||||
this.scheduleCleanup(key);
|
||||
session.client.once("close", () => {
|
||||
const current = this.sessions.get(key);
|
||||
if (current?.client === session.client) {
|
||||
current.isConnected = false;
|
||||
current.cancelCleanup?.();
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
touch(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return;
|
||||
session.lastActive = Date.now();
|
||||
this.scheduleCleanup(key);
|
||||
}
|
||||
|
||||
/** Closes a session, or waits for the operations still using it. */
|
||||
close(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return;
|
||||
if (session.activeOperations > 0) {
|
||||
this.log.warn(
|
||||
`Deferring metrics session cleanup - ${session.activeOperations} active operations`,
|
||||
{ operation: "cleanup_deferred", sessionKey: key },
|
||||
);
|
||||
this.scheduleCleanup(key);
|
||||
return;
|
||||
}
|
||||
session.cancelCleanup?.();
|
||||
this.sessions.delete(key);
|
||||
endQuietly(session.client);
|
||||
}
|
||||
|
||||
addPending(session: PendingTOTPSession): void {
|
||||
this.pending.set(session.sessionId, session);
|
||||
}
|
||||
|
||||
getPending(sessionId: string): PendingTOTPSession | undefined {
|
||||
return this.pending.get(sessionId);
|
||||
}
|
||||
|
||||
dropPending(sessionId: string, endClient = false): void {
|
||||
const session = this.pending.get(sessionId);
|
||||
this.pending.delete(sessionId);
|
||||
if (session && endClient) endQuietly(session.client);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const key of [...this.sessions.keys()]) {
|
||||
const session = this.sessions.get(key)!;
|
||||
session.cancelCleanup?.();
|
||||
endQuietly(session.client);
|
||||
}
|
||||
this.sessions.clear();
|
||||
for (const session of this.pending.values()) endQuietly(session.client);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private scheduleCleanup(key: string): void {
|
||||
const session = this.sessions.get(key);
|
||||
if (!session) return;
|
||||
session.cancelCleanup?.();
|
||||
session.cancelCleanup = this.schedule.after(IDLE_MS, () => {
|
||||
session.cancelCleanup = undefined;
|
||||
this.close(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import type { Express, RequestHandler } from "express";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
|
||||
type HostMetricsSettingsConfig = {
|
||||
statusCheckInterval: number;
|
||||
metricsInterval: number;
|
||||
};
|
||||
|
||||
type HostMetricsSettingsRoutesDeps = {
|
||||
requireAdmin: RequestHandler;
|
||||
defaultStatsConfig: HostMetricsSettingsConfig;
|
||||
refreshAllPolling: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isMissingSettingsTable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error && error.message.includes("no such table: settings")
|
||||
);
|
||||
}
|
||||
|
||||
export function registerHostMetricsSettingsRoutes(
|
||||
app: Express,
|
||||
{
|
||||
requireAdmin,
|
||||
defaultStatsConfig: DEFAULT_STATS_CONFIG,
|
||||
refreshAllPolling,
|
||||
}: HostMetricsSettingsRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /global-settings:
|
||||
* get:
|
||||
* summary: Get global monitoring defaults
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Global monitoring settings.
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.get("/global-settings", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const statusValue = await settings.get("global_status_check_interval");
|
||||
const metricsValue = await settings.get("global_metrics_interval");
|
||||
|
||||
res.json({
|
||||
statusCheckInterval: statusValue
|
||||
? parseInt(statusValue, 10)
|
||||
: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: metricsValue
|
||||
? parseInt(metricsValue, 10)
|
||||
: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingSettingsTable(error)) {
|
||||
statsLogger.warn("Settings table does not exist, using defaults", {
|
||||
operation: "global_settings_table_check",
|
||||
error: error.message,
|
||||
});
|
||||
return res.json({
|
||||
statusCheckInterval: DEFAULT_STATS_CONFIG.statusCheckInterval,
|
||||
metricsInterval: DEFAULT_STATS_CONFIG.metricsInterval,
|
||||
});
|
||||
}
|
||||
|
||||
statsLogger.error("Failed to fetch global settings", {
|
||||
operation: "global_settings_fetch_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch global settings" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /global-settings:
|
||||
* post:
|
||||
* summary: Update global monitoring defaults
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* statusCheckInterval:
|
||||
* type: integer
|
||||
* metricsInterval:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settings saved.
|
||||
* 400:
|
||||
* description: Invalid parameters.
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.post("/global-settings", requireAdmin, async (req, res) => {
|
||||
const { statusCheckInterval, metricsInterval } = req.body;
|
||||
|
||||
if (
|
||||
statusCheckInterval !== undefined &&
|
||||
(typeof statusCheckInterval !== "number" ||
|
||||
statusCheckInterval < 5 ||
|
||||
statusCheckInterval > 3600)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "statusCheckInterval must be between 5 and 3600 seconds",
|
||||
});
|
||||
}
|
||||
if (
|
||||
metricsInterval !== undefined &&
|
||||
(typeof metricsInterval !== "number" ||
|
||||
metricsInterval < 5 ||
|
||||
metricsInterval > 3600)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "metricsInterval must be between 5 and 3600 seconds" });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
|
||||
if (statusCheckInterval !== undefined) {
|
||||
await settings.set(
|
||||
"global_status_check_interval",
|
||||
String(statusCheckInterval),
|
||||
);
|
||||
}
|
||||
if (metricsInterval !== undefined) {
|
||||
await settings.set("global_metrics_interval", String(metricsInterval));
|
||||
}
|
||||
|
||||
await refreshAllPolling();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Settings updated and polling refreshed",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMissingSettingsTable(error)) {
|
||||
statsLogger.error(
|
||||
"Settings table does not exist, cannot save settings",
|
||||
{
|
||||
operation: "global_settings_table_check",
|
||||
error: error.message,
|
||||
},
|
||||
);
|
||||
return res.status(500).json({
|
||||
error:
|
||||
"Database settings table is missing. Please check database initialization.",
|
||||
});
|
||||
}
|
||||
|
||||
statsLogger.error("Failed to save global settings", {
|
||||
operation: "global_settings_save_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to save global settings",
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /global-settings/history:
|
||||
* get:
|
||||
* summary: Get metrics history retention setting
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Retention setting in days.
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.get("/global-settings/history", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const value = await createCurrentSettingsRepository().get(
|
||||
"metrics_history_retention_days",
|
||||
);
|
||||
const days = value ? parseInt(value, 10) : 7;
|
||||
res.json({ metricsHistoryRetentionDays: isNaN(days) ? 7 : days });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to fetch history retention setting", {
|
||||
operation: "history_retention_fetch_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to fetch history retention setting" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /global-settings/history:
|
||||
* post:
|
||||
* summary: Update metrics history retention setting
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* metricsHistoryRetentionDays:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* maximum: 90
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Setting saved.
|
||||
* 400:
|
||||
* description: Invalid value.
|
||||
* 403:
|
||||
* description: Requires admin privileges.
|
||||
*/
|
||||
app.post("/global-settings/history", requireAdmin, async (req, res) => {
|
||||
const { metricsHistoryRetentionDays } = req.body;
|
||||
if (
|
||||
typeof metricsHistoryRetentionDays !== "number" ||
|
||||
metricsHistoryRetentionDays < 1 ||
|
||||
metricsHistoryRetentionDays > 90
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "metricsHistoryRetentionDays must be between 1 and 90",
|
||||
});
|
||||
}
|
||||
try {
|
||||
await createCurrentSettingsRepository().set(
|
||||
"metrics_history_retention_days",
|
||||
String(Math.floor(metricsHistoryRetentionDays)),
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to save history retention setting", {
|
||||
operation: "history_retention_save_error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to save history retention setting" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
export type HostStatus = "online" | "reachable" | "offline";
|
||||
|
||||
export class RequestQueue {
|
||||
private queues = new Map<number, Array<() => Promise<unknown>>>();
|
||||
private processing = new Set<number>();
|
||||
private requestTimeout = 60000;
|
||||
|
||||
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const wrappedRequest = async () => {
|
||||
try {
|
||||
const result = await Promise.race<T>([
|
||||
request(),
|
||||
new Promise<never>((_, rej) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
rej(
|
||||
new Error(
|
||||
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
|
||||
),
|
||||
),
|
||||
this.requestTimeout,
|
||||
),
|
||||
),
|
||||
]);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
queue.push(wrappedRequest);
|
||||
this.queues.set(hostId, queue);
|
||||
this.processQueue(hostId);
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(hostId: number): Promise<void> {
|
||||
if (this.processing.has(hostId)) return;
|
||||
|
||||
this.processing.add(hostId);
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const request = queue.shift();
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.delete(hostId);
|
||||
const currentQueue = this.queues.get(hostId);
|
||||
if (currentQueue && currentQueue.length > 0) {
|
||||
this.processQueue(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedMetrics {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
export class MetricsCache {
|
||||
private cache = new Map<number, CachedMetrics>();
|
||||
private ttl = 30000;
|
||||
|
||||
get(hostId: number): unknown | null {
|
||||
const cached = this.cache.get(hostId);
|
||||
if (cached && Date.now() - cached.timestamp < this.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set(hostId: number, data: unknown): void {
|
||||
this.cache.set(hostId, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
hostId,
|
||||
});
|
||||
}
|
||||
|
||||
clear(hostId?: number): void {
|
||||
if (hostId) {
|
||||
this.cache.delete(hostId);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthFailureRecord {
|
||||
count: number;
|
||||
lastFailure: number;
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
export class AuthFailureTracker {
|
||||
private failures = new Map<number, AuthFailureRecord>();
|
||||
private maxRetries = 3;
|
||||
private backoffBase = 5000;
|
||||
|
||||
recordFailure(
|
||||
hostId: number,
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
|
||||
permanent = false,
|
||||
): void {
|
||||
const existing = this.failures.get(hostId);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.lastFailure = Date.now();
|
||||
existing.reason = reason;
|
||||
if (permanent) existing.permanent = true;
|
||||
} else {
|
||||
this.failures.set(hostId, {
|
||||
count: 1,
|
||||
lastFailure: Date.now(),
|
||||
reason,
|
||||
permanent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return false;
|
||||
|
||||
if (record.reason === "TOTP" || record.permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
|
||||
return timeSinceFailure < backoffTime;
|
||||
}
|
||||
|
||||
getSkipReason(hostId: number): string | null {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return null;
|
||||
|
||||
if (record.reason === "TOTP") {
|
||||
return "TOTP authentication required (metrics unavailable)";
|
||||
}
|
||||
|
||||
if (record.reason === "HOST_KEY") {
|
||||
return "SSH host key changed (verification required)";
|
||||
}
|
||||
|
||||
if (record.permanent) {
|
||||
return "Authentication permanently failed";
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return `Too many authentication failures (${record.count} attempts)`;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
|
||||
|
||||
if (timeSinceFailure < backoffTime) {
|
||||
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
resetHostKeyFailure(hostId: number): void {
|
||||
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, record] of this.failures.entries()) {
|
||||
if (!record.permanent && now - record.lastFailure > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PollingBackoff {
|
||||
private failures = new Map<number, { count: number; nextRetry: number }>();
|
||||
private baseDelay = 30000;
|
||||
private maxDelay = 600000;
|
||||
private maxRetries = 5;
|
||||
|
||||
recordFailure(hostId: number): void {
|
||||
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
|
||||
const delay = Math.min(
|
||||
this.baseDelay * Math.pow(2, existing.count),
|
||||
this.maxDelay,
|
||||
);
|
||||
this.failures.set(hostId, {
|
||||
count: existing.count + 1,
|
||||
nextRetry: Date.now() + delay,
|
||||
});
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return false;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() < backoff.nextRetry;
|
||||
}
|
||||
|
||||
getBackoffInfo(hostId: number): string | null {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return null;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
|
||||
}
|
||||
|
||||
const remainingMs = backoff.nextRetry - Date.now();
|
||||
if (remainingMs > 0) {
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, backoff] of this.failures.entries()) {
|
||||
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
|
||||
* Used to stop metrics polls from stampeding under load.
|
||||
*/
|
||||
export class ConcurrentLimiter {
|
||||
private active = 0;
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
constructor(private maxConcurrent: number) {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.waiters.length;
|
||||
}
|
||||
|
||||
get limit(): number {
|
||||
return this.maxConcurrent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the ceiling at runtime.
|
||||
*
|
||||
* Raising it wakes the waiters the new headroom allows, so a queue that built
|
||||
* up under the old limit drains at once instead of one job at a time as
|
||||
* running work finishes. Lowering it never interrupts work already running;
|
||||
* the new limit simply applies from the next release onward.
|
||||
*/
|
||||
setLimit(maxConcurrent: number): void {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes as many queued callers as there is now room for.
|
||||
*
|
||||
* `woken` counts callers that have been resumed but have not yet reached the
|
||||
* `active += 1` on the far side of their await. Without it the occupancy
|
||||
* looks lower than it really is for a microtask, and the loop would release
|
||||
* past the ceiling.
|
||||
*/
|
||||
private releaseWaiters(): void {
|
||||
while (
|
||||
this.active + this.woken < this.maxConcurrent &&
|
||||
this.waiters.length > 0
|
||||
) {
|
||||
this.woken += 1;
|
||||
this.waiters.shift()!();
|
||||
}
|
||||
}
|
||||
|
||||
private woken = 0;
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Queue behind anything already woken, so a caller arriving mid-handoff
|
||||
// cannot jump the queue and push occupancy past the ceiling.
|
||||
if (this.active + this.woken >= this.maxConcurrent) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
// Resumed by a slot handoff; that reservation is now consumed.
|
||||
this.woken = Math.max(0, this.woken - 1);
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many metrics polls may run at once for a given number of polled hosts.
|
||||
*
|
||||
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
|
||||
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
|
||||
* interval, so polling fell permanently behind and a host could wait over a
|
||||
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
|
||||
* its interval; the cap keeps file descriptors and CPU bounded.
|
||||
*/
|
||||
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
|
||||
const MIN_METRICS_CONCURRENCY = 5;
|
||||
const MAX_METRICS_CONCURRENCY = 50;
|
||||
/** Aim to spend about a twentieth of the interval per sweep wave. */
|
||||
const HOSTS_PER_WORKER = 20;
|
||||
|
||||
export function metricsConcurrencyFor(
|
||||
hostCount: number,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const override = Number(env[METRICS_CONCURRENCY_ENV]);
|
||||
if (Number.isFinite(override) && override >= 1) {
|
||||
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
|
||||
}
|
||||
|
||||
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
|
||||
return Math.min(
|
||||
Math.max(scaled, MIN_METRICS_CONCURRENCY),
|
||||
MAX_METRICS_CONCURRENCY,
|
||||
);
|
||||
}
|
||||
|
||||
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
|
||||
export class HostPollCache<THost extends { id: number } = { id: number }> {
|
||||
private cache = new Map<
|
||||
number,
|
||||
{ host: THost; userId: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
constructor(private readonly ttlMs = 30_000) {}
|
||||
|
||||
get(hostId: number, userId: string): THost | null {
|
||||
const entry = this.cache.get(hostId);
|
||||
if (!entry) return null;
|
||||
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
|
||||
this.cache.delete(hostId);
|
||||
return null;
|
||||
}
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
set(hostId: number, userId: string, host: THost): void {
|
||||
this.cache.set(hostId, {
|
||||
host,
|
||||
userId,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
this.cache.clear();
|
||||
return;
|
||||
}
|
||||
this.cache.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first heavy SSH sample waits until core's cheap status check says the
|
||||
* host answers. A host core does not check (status checks off) is tried.
|
||||
*/
|
||||
export function canStartInitialMetrics(
|
||||
status: HostStatus | null | undefined,
|
||||
hasViewers: boolean,
|
||||
): boolean {
|
||||
return hasViewers && status !== "offline";
|
||||
}
|
||||
|
||||
/** Everything one activation of the plugin polls with. */
|
||||
export function createMetricsState() {
|
||||
return {
|
||||
/** SSH metrics execs are expensive; resized with the fleet. */
|
||||
metricsLimiter: new ConcurrentLimiter(5),
|
||||
/** Viewer registration is bursty; admit only two first samples at a time. */
|
||||
initialLimiter: new ConcurrentLimiter(2),
|
||||
hostCache: new HostPollCache(30_000),
|
||||
requestQueue: new RequestQueue(),
|
||||
metricsCache: new MetricsCache(),
|
||||
authFailures: new AuthFailureTracker(),
|
||||
backoff: new PollingBackoff(),
|
||||
};
|
||||
}
|
||||
|
||||
export type MetricsState = ReturnType<typeof createMetricsState>;
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
real,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/** One card layout per user per host, as JSON. */
|
||||
export const hostMetricsPreferences = adoptLegacyTable(
|
||||
"host_metrics_preferences",
|
||||
defineTable(
|
||||
"host_metrics_preferences",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
layout: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_host_metrics_prefs_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** A user's health checks for a host, as a JSON array. */
|
||||
export const hostHealthChecks = adoptLegacyTable(
|
||||
"host_health_checks",
|
||||
defineTable(
|
||||
"host_health_checks",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
checks: text().notNull(),
|
||||
intervalSeconds: integer().notNull().default(300),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{
|
||||
name: "idx_host_health_checks_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const hostHealthHistory = adoptLegacyTable(
|
||||
"host_health_history",
|
||||
defineTable(
|
||||
"host_health_history",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
checkId: varchar().notNull(),
|
||||
ts: timestamp().notNull().defaultNow(),
|
||||
ok: boolean().notNull(),
|
||||
latencyMs: integer(),
|
||||
detail: text(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_host_health_history_lookup",
|
||||
columns: ["userId", "hostId", "checkId", "ts"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** One row per metrics sample, pruned to the retention setting. */
|
||||
export const hostMetricsHistory = adoptLegacyTable(
|
||||
"host_metrics_history",
|
||||
defineTable(
|
||||
"host_metrics_history",
|
||||
{
|
||||
id: id(),
|
||||
hostId: refHost(),
|
||||
ts: timestamp().notNull().defaultNow(),
|
||||
cpuPercent: real(),
|
||||
memPercent: real(),
|
||||
diskPercent: real(),
|
||||
netRxBytes: integer(),
|
||||
netTxBytes: integer(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_host_metrics_history_host_ts",
|
||||
columns: ["hostId", "ts"],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [
|
||||
hostMetricsPreferences,
|
||||
hostHealthChecks,
|
||||
hostHealthHistory,
|
||||
hostMetricsHistory,
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
export function toFixedNum(
|
||||
n: number | null | undefined,
|
||||
digits = 2,
|
||||
): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
export function kibToGiB(kib: number): number {
|
||||
return kib / (1024 * 1024);
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = "Unknown error") {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import type { Express } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
|
||||
type ViewerStatsConfig = {
|
||||
metricsEnabled: boolean;
|
||||
};
|
||||
|
||||
type HostMetricsViewerRoutesDeps<
|
||||
THost extends { statsConfig?: string | TStatsConfig },
|
||||
TStatsConfig extends ViewerStatsConfig,
|
||||
> = {
|
||||
fetchHostById: (hostId: number, userId: string) => Promise<THost>;
|
||||
supportsMetrics: (host: THost) => boolean;
|
||||
parseStatsConfig: (statsConfig: THost["statsConfig"]) => TStatsConfig;
|
||||
updateHeartbeat: (viewerSessionId: string) => boolean;
|
||||
registerViewer: (
|
||||
hostId: number,
|
||||
viewerSessionId: string,
|
||||
userId: string,
|
||||
) => void;
|
||||
unregisterViewer: (hostId: number, viewerSessionId: string) => void;
|
||||
/** Route path segment, e.g. "metrics" -> /metrics/heartbeat. Defaults to "metrics". */
|
||||
pathPrefix?: string;
|
||||
};
|
||||
|
||||
export function registerHostMetricsViewerRoutes<
|
||||
THost extends { statsConfig?: string | TStatsConfig },
|
||||
TStatsConfig extends ViewerStatsConfig,
|
||||
>(
|
||||
app: Express,
|
||||
{
|
||||
fetchHostById,
|
||||
supportsMetrics,
|
||||
parseStatsConfig,
|
||||
updateHeartbeat,
|
||||
registerViewer,
|
||||
unregisterViewer,
|
||||
pathPrefix = "metrics",
|
||||
}: HostMetricsViewerRoutesDeps<THost, TStatsConfig>,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/heartbeat:
|
||||
* post:
|
||||
* summary: Update viewer heartbeat
|
||||
* description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Heartbeat updated successfully.
|
||||
* 400:
|
||||
* description: Invalid viewerSessionId.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
* 404:
|
||||
* description: Viewer session not found.
|
||||
* 500:
|
||||
* description: Failed to update heartbeat.
|
||||
*/
|
||||
app.post(`/${pathPrefix}/heartbeat`, async (req, res) => {
|
||||
const { viewerSessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const success = updateHeartbeat(viewerSessionId);
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: "Viewer session not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to update heartbeat", {
|
||||
operation: "heartbeat_error",
|
||||
viewerSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update heartbeat" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/register-viewer:
|
||||
* post:
|
||||
* summary: Register metrics viewer
|
||||
* description: Registers a new viewer session for a host to track who is viewing metrics.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Viewer registered successfully.
|
||||
* 400:
|
||||
* description: Invalid hostId.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
* 500:
|
||||
* description: Failed to register viewer.
|
||||
*/
|
||||
app.post(`/${pathPrefix}/register-viewer`, async (req, res) => {
|
||||
const { hostId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Graceful no-op if host is inaccessible, metrics disabled, or host type
|
||||
// does not support metrics. The client may call this speculatively, so
|
||||
// avoid returning 5xx for expected "no metrics available" scenarios.
|
||||
let host: THost | undefined;
|
||||
try {
|
||||
host = await fetchHostById(hostId, userId);
|
||||
} catch (lookupErr) {
|
||||
statsLogger.warn(
|
||||
"register-viewer host lookup failed (treating as no-op)",
|
||||
{
|
||||
operation: "register_viewer_lookup",
|
||||
hostId,
|
||||
userId,
|
||||
error: getErrorMessage(lookupErr, String(lookupErr)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "host_not_found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!supportsMetrics(host)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "metrics_unsupported",
|
||||
});
|
||||
}
|
||||
|
||||
const statsConfig = parseStatsConfig(host.statsConfig);
|
||||
if (!statsConfig.metricsEnabled) {
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "metrics_disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const viewerSessionId = `viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
try {
|
||||
registerViewer(hostId, viewerSessionId, userId);
|
||||
} catch (regErr) {
|
||||
statsLogger.warn(
|
||||
"pollingManager.registerViewer threw (treating as no-op)",
|
||||
{
|
||||
operation: "register_viewer_internal",
|
||||
hostId,
|
||||
userId,
|
||||
error: regErr instanceof Error ? regErr.message : String(regErr),
|
||||
},
|
||||
);
|
||||
return res.json({
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: "register_failed_noop",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, viewerSessionId });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to register viewer", {
|
||||
operation: "register_viewer_error",
|
||||
hostId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// Even on unexpected errors we prefer a graceful client experience: the
|
||||
// viewer-registration is purely an optimization and should never break
|
||||
// the UI. Report success:false but HTTP 200 so the client can decide.
|
||||
res.status(200).json({
|
||||
success: false,
|
||||
skipped: true,
|
||||
reason: "internal_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /metrics/unregister-viewer:
|
||||
* post:
|
||||
* summary: Unregister metrics viewer
|
||||
* description: Unregisters a viewer session when they stop viewing metrics for a host.
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: integer
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Viewer unregistered successfully.
|
||||
* 400:
|
||||
* description: Invalid hostId or viewerSessionId.
|
||||
* 401:
|
||||
* description: Session expired - please log in again.
|
||||
* 500:
|
||||
* description: Failed to unregister viewer.
|
||||
*/
|
||||
app.post(`/${pathPrefix}/unregister-viewer`, async (req, res) => {
|
||||
const { hostId, viewerSessionId } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostId || typeof hostId !== "number") {
|
||||
return res.status(400).json({ error: "Invalid hostId" });
|
||||
}
|
||||
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
|
||||
try {
|
||||
unregisterViewer(hostId, viewerSessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
statsLogger.error("Failed to unregister viewer", {
|
||||
operation: "unregister_viewer_error",
|
||||
hostId,
|
||||
viewerSessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unregister viewer" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
toFixedNum,
|
||||
type HostPlatform,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
/**
|
||||
* CPU percent is derived from the delta against the previous poll's
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
toFixedNum,
|
||||
type HostPlatform,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import type {
|
||||
FirewallMetrics,
|
||||
FirewallChain,
|
||||
FirewallRule,
|
||||
} from "../../../../../src/types/stats-widgets.js";
|
||||
} from "../../shared/stats-widgets.js";
|
||||
|
||||
function parseIptablesRule(line: string): FirewallRule | null {
|
||||
if (!line.startsWith("-A ")) return null;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
toFixedNum,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface GpuDevice {
|
||||
index: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface LoginRecord {
|
||||
user: string;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
toFixedNum,
|
||||
kibToGiB,
|
||||
type HostPlatform,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum, kibToGiB } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
async function collectDarwinMemoryMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
export interface NetworkCounters {
|
||||
rx: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import type {
|
||||
PortsMetrics,
|
||||
ListeningPort,
|
||||
} from "../../../../../src/types/stats-widgets.js";
|
||||
} from "../../shared/stats-widgets.js";
|
||||
|
||||
function parseSsOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export async function collectProcessesMetrics(client: Client): Promise<{
|
||||
total: number | null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
async function collectWindowsSystemMetrics(client: Client): Promise<{
|
||||
hostname: string | null;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { toFixedNum } from "../util.js";
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
toFixedNum,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface TemperatureSensor {
|
||||
label: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
function formatUptime(uptimeSeconds: number): string {
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user