mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: ship host metrics as a first-party in-process plugin
This commit is contained in:
1 parent
a2479c19b0
commit
19b70430a1
145 files changed
+675
-349
No files matched your search
@@ -45,9 +45,8 @@ export async function register({
|
||||
});
|
||||
|
||||
if (registerHostEditorTab) {
|
||||
const { HostDockerTab } = await import(
|
||||
"../../../src/ui/sidebar/HostEditorFeatureTabs.tsx"
|
||||
);
|
||||
const { HostDockerTab } =
|
||||
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
|
||||
registerHostEditorTab({
|
||||
id: hostEditorTabId,
|
||||
labelKey: "hosts.tabDocker",
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { createCurrentAlertRepository } from "../../../src/backend/database/repositories/factory.js";
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
import {
|
||||
sendNotification,
|
||||
type AlertPayload,
|
||||
type NotificationChannel,
|
||||
} from "../../../src/backend/utils/notification-sender.js";
|
||||
import { sendDiscord } from "../../../src/backend/utils/discord-sender.js";
|
||||
|
||||
type AlertTriggerType =
|
||||
| "host_offline"
|
||||
| "host_online"
|
||||
| "cpu_threshold"
|
||||
| "memory_threshold"
|
||||
| "disk_threshold"
|
||||
| "health_check_failure"
|
||||
| "health_check_recovery"
|
||||
| "user_login";
|
||||
|
||||
interface AlertRule {
|
||||
id: number;
|
||||
userId: string;
|
||||
hostId: number | null;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
triggerType: AlertTriggerType;
|
||||
thresholdValue: number | null;
|
||||
thresholdDurationSeconds: number | null;
|
||||
cooldownMinutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set once the alert rules have been copied into automations. From then on the
|
||||
* automations engine owns evaluation, and this one stands down rather than
|
||||
* sending a second notification for every rule.
|
||||
*
|
||||
* The class is left in place so an install that has not migrated yet, or one
|
||||
* rolled back, still alerts exactly as before.
|
||||
*/
|
||||
let supersededByAutomations = false;
|
||||
|
||||
export function markAlertEngineSuperseded(): void {
|
||||
supersededByAutomations = true;
|
||||
}
|
||||
|
||||
export function isAlertEngineSuperseded(): boolean {
|
||||
return supersededByAutomations;
|
||||
}
|
||||
|
||||
export class AlertEngine {
|
||||
private static instance: AlertEngine;
|
||||
|
||||
// "${ruleId}:${hostId}" → lastFiredAt ms
|
||||
private cooldownMap = new Map<string, number>();
|
||||
// "${ruleId}:${hostId}" → breachStartTime ms
|
||||
private breachStartMap = new Map<string, number>();
|
||||
// hostId → last known status
|
||||
private lastStatusMap = new Map<number, "online" | "offline">();
|
||||
// "${hostId}:${checkId}" → last known ok state
|
||||
private healthCheckStateMap = new Map<string, boolean>();
|
||||
|
||||
static getInstance(): AlertEngine {
|
||||
if (!AlertEngine.instance) {
|
||||
AlertEngine.instance = new AlertEngine();
|
||||
}
|
||||
return AlertEngine.instance;
|
||||
}
|
||||
|
||||
async evaluateMetrics(
|
||||
hostId: number,
|
||||
metrics: {
|
||||
cpu?: { percent: number | null } | null;
|
||||
memory?: { percent: number | null } | null;
|
||||
disk?: { percent: number | null } | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const rules = (await this.loadRulesForHost(hostId)).filter((r) =>
|
||||
["cpu_threshold", "memory_threshold", "disk_threshold"].includes(
|
||||
r.triggerType,
|
||||
),
|
||||
);
|
||||
|
||||
if (rules.length === 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const rule of rules) {
|
||||
let currentValue: number | null | undefined;
|
||||
if (rule.triggerType === "cpu_threshold")
|
||||
currentValue = metrics.cpu?.percent;
|
||||
else if (rule.triggerType === "memory_threshold")
|
||||
currentValue = metrics.memory?.percent;
|
||||
else if (rule.triggerType === "disk_threshold")
|
||||
currentValue = metrics.disk?.percent;
|
||||
|
||||
if (currentValue == null || rule.thresholdValue == null) continue;
|
||||
|
||||
const key = `${rule.id}:${hostId}`;
|
||||
const isBreaching = currentValue >= rule.thresholdValue;
|
||||
|
||||
if (isBreaching) {
|
||||
if (!this.breachStartMap.has(key)) {
|
||||
this.breachStartMap.set(key, now);
|
||||
}
|
||||
const breachStart = this.breachStartMap.get(key)!;
|
||||
const durationMs = (rule.thresholdDurationSeconds ?? 0) * 1000;
|
||||
if (
|
||||
now - breachStart >= durationMs &&
|
||||
!(await this.isCoolingDown(rule.id, hostId))
|
||||
) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
value: currentValue,
|
||||
message: `${rule.triggerType.replace("_threshold", "").toUpperCase()} usage at ${currentValue.toFixed(1)}% (threshold: ${rule.thresholdValue}%)`,
|
||||
severity: currentValue >= 95 ? "critical" : "warning",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.breachStartMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateStatus(hostId: number, isOnline: boolean): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const currentStatus = isOnline ? "online" : "offline";
|
||||
const lastStatus = this.lastStatusMap.get(hostId);
|
||||
|
||||
if (lastStatus === currentStatus) return;
|
||||
this.lastStatusMap.set(hostId, currentStatus);
|
||||
|
||||
if (lastStatus === undefined) return;
|
||||
|
||||
const triggerType: AlertTriggerType = isOnline
|
||||
? "host_online"
|
||||
: "host_offline";
|
||||
const rules = (await this.loadRulesForHost(hostId)).filter(
|
||||
(r) => r.triggerType === triggerType,
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: isOnline
|
||||
? `Host "${hostName}" is back online`
|
||||
: `Host "${hostName}" is offline`,
|
||||
severity: isOnline ? "info" : "critical",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateHealthCheck(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
checkId: string,
|
||||
ok: boolean,
|
||||
detail?: string,
|
||||
): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const stateKey = `${hostId}:${checkId}`;
|
||||
const lastOk = this.healthCheckStateMap.get(stateKey);
|
||||
this.healthCheckStateMap.set(stateKey, ok);
|
||||
|
||||
if (lastOk === undefined) return;
|
||||
|
||||
let triggerType: AlertTriggerType | null = null;
|
||||
if (!ok && lastOk) triggerType = "health_check_failure";
|
||||
else if (ok && !lastOk) triggerType = "health_check_recovery";
|
||||
|
||||
if (!triggerType) return;
|
||||
|
||||
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
|
||||
(r) => r.triggerType === triggerType,
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: ok
|
||||
? `Health check recovered on "${hostName}"${detail ? `: ${detail}` : ""}`
|
||||
: `Health check failed on "${hostName}"${detail ? `: ${detail}` : ""}`,
|
||||
severity: ok ? "info" : "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateUserLogin(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
sshUser: string,
|
||||
fromIp: string,
|
||||
): Promise<void> {
|
||||
if (supersededByAutomations) return;
|
||||
const rules = (await this.loadRulesForHostUser(hostId, userId)).filter(
|
||||
(r) => r.triggerType === "user_login",
|
||||
);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!(await this.isCoolingDown(rule.id, hostId))) {
|
||||
const hostName = await this.getHostName(hostId);
|
||||
await this.fireAlert(rule, hostId, hostName, {
|
||||
message: `User "${sshUser}" logged in to "${hostName}" from ${fromIp}`,
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fireAlert(
|
||||
rule: AlertRule,
|
||||
hostId: number,
|
||||
hostName: string,
|
||||
context: {
|
||||
value?: number;
|
||||
message: string;
|
||||
severity: "info" | "warning" | "critical";
|
||||
},
|
||||
): Promise<void> {
|
||||
this.markCooldown(rule.id, hostId);
|
||||
|
||||
const payload: AlertPayload = {
|
||||
hostName,
|
||||
hostId,
|
||||
triggerType: rule.triggerType,
|
||||
value: context.value,
|
||||
threshold: rule.thresholdValue ?? undefined,
|
||||
message: context.message,
|
||||
severity: context.severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
ruleId: rule.id,
|
||||
ruleName: rule.name,
|
||||
};
|
||||
|
||||
try {
|
||||
const repository = createCurrentAlertRepository();
|
||||
await repository.createFiring({
|
||||
userId: rule.userId,
|
||||
ruleId: rule.id,
|
||||
hostId,
|
||||
hostName,
|
||||
value: context.value ?? null,
|
||||
message: context.message,
|
||||
severity: context.severity,
|
||||
});
|
||||
|
||||
await repository.pruneFiringsOlderThan(rule.userId, 30);
|
||||
} catch (err) {
|
||||
statsLogger.warn("Failed to write alert firing", {
|
||||
operation: "alert_firing_insert_error",
|
||||
ruleId: rule.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const channels = await this.loadChannelsForRule(rule.id);
|
||||
for (const channel of channels) {
|
||||
if (channel.type === "discord") {
|
||||
// parse config and send via Discord sender directly
|
||||
try {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(channel.config) as Record<string, unknown>;
|
||||
} catch {
|
||||
statsLogger.warn("Failed to parse discord channel config", {
|
||||
operation: "discord_config_parse_error",
|
||||
channelId: channel.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
sendDiscord(parsed as any, payload).catch((err) => {
|
||||
statsLogger.warn("Failed to send discord notification", {
|
||||
operation: "discord_notification_error",
|
||||
channelId: channel.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
statsLogger.warn("Failed to send discord notification", {
|
||||
operation: "discord_notification_error",
|
||||
channelId: channel.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sendNotification(channel, payload).catch((err) => {
|
||||
statsLogger.warn("Failed to send notification", {
|
||||
operation: "notification_delivery_error",
|
||||
channelId: channel.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCoolingDown(
|
||||
ruleId: number,
|
||||
hostId: number,
|
||||
): Promise<boolean> {
|
||||
const key = `${ruleId}:${hostId}`;
|
||||
const lastFired = this.cooldownMap.get(key);
|
||||
if (!lastFired) return false;
|
||||
const rule = await this.loadRuleById(ruleId);
|
||||
const cooldownMs = (rule?.cooldownMinutes ?? 15) * 60 * 1000;
|
||||
return Date.now() - lastFired < cooldownMs;
|
||||
}
|
||||
|
||||
private markCooldown(ruleId: number, hostId: number): void {
|
||||
this.cooldownMap.set(`${ruleId}:${hostId}`, Date.now());
|
||||
}
|
||||
|
||||
private async loadRulesForHost(hostId: number): Promise<AlertRule[]> {
|
||||
try {
|
||||
return (await createCurrentAlertRepository().listEnabledRulesForHost(
|
||||
hostId,
|
||||
)) as AlertRule[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRulesForHostUser(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<AlertRule[]> {
|
||||
try {
|
||||
return (await createCurrentAlertRepository().listEnabledRulesForHostUser(
|
||||
hostId,
|
||||
userId,
|
||||
)) as AlertRule[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRuleById(ruleId: number): Promise<AlertRule | null> {
|
||||
try {
|
||||
return (await createCurrentAlertRepository().findRuleById(
|
||||
ruleId,
|
||||
)) as AlertRule | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadChannelsForRule(
|
||||
ruleId: number,
|
||||
): Promise<NotificationChannel[]> {
|
||||
try {
|
||||
return await createCurrentAlertRepository().listEnabledChannelsForRule(
|
||||
ruleId,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getHostName(hostId: number): Promise<string> {
|
||||
try {
|
||||
return (
|
||||
(await createCurrentAlertRepository().getHostDisplayName(hostId)) ??
|
||||
`Host #${hostId}`
|
||||
);
|
||||
} catch {
|
||||
return `Host #${hostId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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 "../../../src/backend/automations/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,62 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { usesIssuedCertificate } from "../../../src/backend/hosts/issued-certificate-auth.js";
|
||||
|
||||
export type StatsCapableHost = {
|
||||
connectionType?: string;
|
||||
authType?: string;
|
||||
};
|
||||
|
||||
export type TcpPingStatsConfig = {
|
||||
statusCheckEnabled: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
};
|
||||
|
||||
export function supportsMetrics(host: StatsCapableHost): boolean {
|
||||
const connectionType = host.connectionType || "ssh";
|
||||
if (connectionType !== "ssh") return false;
|
||||
if (host.authType === "none" || usesIssuedCertificate(host.authType))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isTcpPingEnabled(statsConfig: TcpPingStatsConfig): boolean {
|
||||
return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
|
||||
}
|
||||
|
||||
export function parseStatusHostIds(value: unknown): Set<number> | null {
|
||||
if (value === undefined) return null;
|
||||
if (typeof value !== "string") return new Set();
|
||||
|
||||
return new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map(Number)
|
||||
.filter((id) => Number.isSafeInteger(id) && id > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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,66 @@
|
||||
/**
|
||||
* Host Metrics - first-party, in-process plugin.
|
||||
*
|
||||
* This plugin runs on the main thread rather than in a worker for the same
|
||||
* reason ssh-terminal and docker do (see src/backend/plugins/first-party.ts):
|
||||
* its polling manager holds long-lived ssh2.Client connections across a
|
||||
* whole fleet of hosts, reused between polling ticks, and its connect flow
|
||||
* implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault
|
||||
* keyboard-interactive handshakes the terminal does. None of that can cross
|
||||
* the structured-clone postMessage boundary a worker plugin talks over, and
|
||||
* the worker ctx.ssh primitive (connect + single exec-and-collect) has no
|
||||
* hook for a session that outlives one request.
|
||||
*
|
||||
* routes.ts is what used to be src/backend/hosts/metrics/index.ts, moved
|
||||
* here and rewritten from a module-scope SIGINT/SIGTERM + listenOnServicePort
|
||||
* call into startHostMetricsService()/shutdown(), called from
|
||||
* activate()/deactivate() below.
|
||||
*
|
||||
* Proxmox-related code (metrics/proxmox/*, proxmox-stats-*) stays in core at
|
||||
* src/backend/hosts/metrics/ rather than moving here -- it is planned as its
|
||||
* own future plugin, so this move does not touch it.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Loads a module by its path relative to this file's own directory.
|
||||
*
|
||||
* The plugin ships beside dist/backend, so in a built server the compiled
|
||||
* .js for the moved host-metrics files (routes.js, ...) sits right next to
|
||||
* this .mjs, and core modules resolve via ../../../backend/backend/... from
|
||||
* there (dist/plugins/host-metrics/backend -> dist/backend/backend). Under
|
||||
* vitest/dev nothing imports this plugin directly (the loader loads
|
||||
* in-process plugins with a plain dynamic import, no tsx loader involved),
|
||||
* so the only layout that needs to resolve here is the built one.
|
||||
*/
|
||||
async function loadRelative(relativePath) {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const target = path.join(here, `${relativePath}.js`);
|
||||
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
|
||||
}
|
||||
|
||||
let routesModule = null;
|
||||
let httpServer = null;
|
||||
|
||||
export async function activate(ctx) {
|
||||
routesModule = await loadRelative("routes");
|
||||
|
||||
httpServer = routesModule.startHostMetricsService();
|
||||
|
||||
ctx.log.info(`Host Metrics listening on ${routesModule.PORT ?? 30005}`);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
if (routesModule) {
|
||||
routesModule.shutdown();
|
||||
routesModule = null;
|
||||
}
|
||||
|
||||
if (httpServer) {
|
||||
const server = httpServer;
|
||||
httpServer = null;
|
||||
await new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface CronEntry {
|
||||
raw: string;
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
|
||||
|
||||
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
|
||||
export function parseCrontab(output: string): CronEntry[] {
|
||||
const entries: CronEntry[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
if (!line.trim()) continue;
|
||||
// A commented-out job: "# <schedule> <command>" (our toggle convention).
|
||||
const disabled = line.match(
|
||||
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
|
||||
);
|
||||
if (disabled) {
|
||||
const { schedule, command } = splitScheduleCommand(disabled[1]);
|
||||
if (command) {
|
||||
entries.push({ raw: line, enabled: false, schedule, command });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Skip pure comments / env assignments.
|
||||
if (line.trimStart().startsWith("#")) continue;
|
||||
if (/^\s*[A-Z_]+=/.test(line)) continue;
|
||||
const { schedule, command } = splitScheduleCommand(line.trim());
|
||||
if (!command) continue;
|
||||
entries.push({ raw: line, enabled: true, schedule, command });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function splitScheduleCommand(line: string): {
|
||||
schedule: string;
|
||||
command: string;
|
||||
} {
|
||||
if (line.startsWith("@")) {
|
||||
const [sched, ...rest] = line.split(/\s+/);
|
||||
return { schedule: sched, command: rest.join(" ") };
|
||||
}
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return { schedule: "", command: "" };
|
||||
return {
|
||||
schedule: parts.slice(0, 5).join(" "),
|
||||
command: parts.slice(5).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
const CRON_SCHEDULE_RE =
|
||||
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
|
||||
|
||||
export function isValidCronSchedule(schedule: string): boolean {
|
||||
return CRON_SCHEDULE_RE.test(schedule.trim());
|
||||
}
|
||||
|
||||
/** Serialize entries into a full crontab body (toggled entries are commented). */
|
||||
export function serializeCrontab(entries: CronEntry[]): string {
|
||||
const lines = entries.map((e) => {
|
||||
const body = `${e.schedule} ${e.command}`.trim();
|
||||
return e.enabled ? body : `# ${body}`;
|
||||
});
|
||||
return lines.join("\n") + (lines.length ? "\n" : "");
|
||||
}
|
||||
|
||||
/** Build a command that atomically replaces the user crontab from the given body. */
|
||||
export function buildApplyCrontabCommand(body: string): string {
|
||||
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
|
||||
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
||||
}
|
||||
|
||||
export function registerCronRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
const { entries } = req.body as { entries?: CronEntry[] };
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new ManagerInputError("entries must be an array");
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (typeof e?.command !== "string" || !e.command.trim()) {
|
||||
throw new ManagerInputError("Each entry needs a command");
|
||||
}
|
||||
if (e.command.includes("\n")) {
|
||||
throw new ManagerInputError("Commands cannot contain newlines");
|
||||
}
|
||||
if (!isValidCronSchedule(String(e.schedule))) {
|
||||
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
|
||||
}
|
||||
}
|
||||
const body = serializeCrontab(entries);
|
||||
const { stdout, stderr, code } = await execCommand(
|
||||
client,
|
||||
buildApplyCrontabCommand(body),
|
||||
15000,
|
||||
);
|
||||
return { success: code === 0, output: stdout || stderr };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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 {
|
||||
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";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface FirewallRuleSpec {
|
||||
protocol: IpProtocol;
|
||||
port: number;
|
||||
target: FirewallTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
|
||||
* specific dport with an explicit target, and never the chain policy, so an
|
||||
* existing ESTABLISHED/SSH rule is left intact.
|
||||
*/
|
||||
export function buildIptablesRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
const flag = op === "add" ? "-A" : "-D";
|
||||
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
|
||||
}
|
||||
|
||||
export function buildNftRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
// nftables uses the inet filter table's input chain by convention.
|
||||
const verb = op === "add" ? "add" : "delete";
|
||||
const action =
|
||||
spec.target.toLowerCase() === "reject"
|
||||
? "reject"
|
||||
: spec.target.toLowerCase();
|
||||
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
|
||||
}
|
||||
|
||||
export function registerFirewallRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/rule",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
const { op, protocol, port, target } = req.body as {
|
||||
op?: "add" | "delete";
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
target?: string;
|
||||
};
|
||||
if (op !== "add" && op !== "delete") {
|
||||
throw new ManagerInputError("Invalid op");
|
||||
}
|
||||
if (!isValidIpProtocol(protocol))
|
||||
throw new ManagerInputError("Invalid protocol");
|
||||
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
|
||||
if (!isValidFirewallTarget(target))
|
||||
throw new ManagerInputError("Invalid target");
|
||||
|
||||
const spec: FirewallRuleSpec = {
|
||||
protocol,
|
||||
port: Number(port),
|
||||
target,
|
||||
};
|
||||
const fw = await collectFirewallMetrics(client);
|
||||
const cmd =
|
||||
fw.type === "nftables"
|
||||
? buildNftRuleCommand(op, spec)
|
||||
: buildIptablesRuleCommand(op, spec);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
backend: fw.type,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/persist",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
// Best-effort persistence across common tools.
|
||||
const cmd =
|
||||
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
|
||||
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
|
||||
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
pkg: platform.pkg,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Express } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { createCurrentHostHealthRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { 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 { AlertEngine } from "../alert-engine.js";
|
||||
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tcp" | "http";
|
||||
target: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface HealthResult {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
|
||||
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
|
||||
|
||||
export function isValidHealthCheck(c: unknown): c is HealthCheck {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const o = c as Record<string, unknown>;
|
||||
if (typeof o.id !== "string" || !o.id) return false;
|
||||
if (typeof o.name !== "string") return false;
|
||||
if (o.type !== "tcp" && o.type !== "http") return false;
|
||||
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
|
||||
if (o.type === "tcp" && !isValidPort(o.port)) return false;
|
||||
if (
|
||||
o.path !== undefined &&
|
||||
(typeof o.path !== "string" || !PATH_RE.test(o.path))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Build a command that runs the check from the host and prints "ok latency". */
|
||||
export function buildHealthCheckCommand(check: HealthCheck): string {
|
||||
if (check.type === "tcp") {
|
||||
const host = shellSingleQuote(check.target);
|
||||
const port = check.port;
|
||||
// Prefer bash /dev/tcp; time it with date in ms.
|
||||
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
|
||||
}
|
||||
// http
|
||||
const scheme = check.target.includes("://") ? "" : "http://";
|
||||
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
|
||||
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
|
||||
}
|
||||
|
||||
export function parseHealthResult(
|
||||
check: HealthCheck,
|
||||
output: string,
|
||||
): HealthResult {
|
||||
const line = output.trim().split("\n").pop() ?? "";
|
||||
if (check.type === "tcp") {
|
||||
const [status, ms] = line.split(/\s+/);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: status === "ok",
|
||||
latencyMs: Number(ms) || null,
|
||||
detail: status === "ok" ? "open" : "closed/timeout",
|
||||
};
|
||||
}
|
||||
const m = line.match(/^(\d{3})\s+([\d.]+)/);
|
||||
if (!m)
|
||||
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
|
||||
const code = Number(m[1]);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: code >= 200 && code < 400,
|
||||
latencyMs: Math.round(Number(m[2]) * 1000),
|
||||
detail: `HTTP ${code}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runChecks(
|
||||
client: Client,
|
||||
checks: HealthCheck[],
|
||||
): Promise<HealthResult[]> {
|
||||
return Promise.all(
|
||||
checks.map(async (check) => {
|
||||
try {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
buildHealthCheckCommand(check),
|
||||
8000,
|
||||
);
|
||||
return parseHealthResult(check, stdout);
|
||||
} catch {
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "error",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHealthRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
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) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
)
|
||||
.catch(() => {});
|
||||
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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/config",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { checks, intervalSeconds } = req.body as {
|
||||
checks?: unknown;
|
||||
intervalSeconds?: number;
|
||||
};
|
||||
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
|
||||
throw new ManagerInputError("Invalid checks");
|
||||
}
|
||||
const interval =
|
||||
typeof intervalSeconds === "number" &&
|
||||
intervalSeconds >= 30 &&
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
const now = new Date().toISOString();
|
||||
await createCurrentHostHealthRepository().upsertChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
now,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
)
|
||||
.catch(() => {});
|
||||
notifyAutomationHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Express } from "express";
|
||||
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { registerServiceRoutes } from "./services.js";
|
||||
import { registerProcessRoutes } from "./processes.js";
|
||||
import { registerSimpleReadRoutes } from "./simple-reads.js";
|
||||
import { registerCronRoutes } from "./cron.js";
|
||||
import { registerPackageRoutes } from "./packages.js";
|
||||
import { registerSslRoutes } from "./ssl.js";
|
||||
import { registerFirewallRoutes } from "./firewall.js";
|
||||
import { registerUserRoutes } from "./users.js";
|
||||
import { registerHealthRoutes } from "./health.js";
|
||||
import { registerLogRoutes } from "./logs.js";
|
||||
import { registerWireGuardRoutes } from "./wireguard.js";
|
||||
import { registerTailscaleRoutes } from "./tailscale.js";
|
||||
|
||||
/**
|
||||
* Registers every Host Metrics manager route under the `/host-metrics/managers`
|
||||
* prefix on the stats app. All routes are on-demand (not polled).
|
||||
*/
|
||||
export function registerManagerRoutes(
|
||||
app: Express,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, runOnHost } = deps;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/platform/{id}:
|
||||
* get:
|
||||
* summary: Detect available management tooling on a host
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Platform capabilities (systemd, package manager, certbot, docker).
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
registerServiceRoutes(app, deps);
|
||||
registerProcessRoutes(app, deps);
|
||||
registerSimpleReadRoutes(app, deps);
|
||||
registerCronRoutes(app, deps);
|
||||
registerPackageRoutes(app, deps);
|
||||
registerSslRoutes(app, deps);
|
||||
registerFirewallRoutes(app, deps);
|
||||
registerUserRoutes(app, deps);
|
||||
registerHealthRoutes(app, deps);
|
||||
registerLogRoutes(app, deps);
|
||||
registerWireGuardRoutes(app, deps);
|
||||
registerTailscaleRoutes(app, deps);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isAllowedPath,
|
||||
isValidSystemdUnit,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
/** Directories from which arbitrary log files may be tailed. */
|
||||
export const LOG_PATH_ALLOWLIST = ["/var/log"];
|
||||
|
||||
const COMMON_LOGS = [
|
||||
"/var/log/syslog",
|
||||
"/var/log/messages",
|
||||
"/var/log/auth.log",
|
||||
"/var/log/secure",
|
||||
"/var/log/kern.log",
|
||||
"/var/log/dpkg.log",
|
||||
"/var/log/nginx/access.log",
|
||||
"/var/log/nginx/error.log",
|
||||
];
|
||||
|
||||
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
|
||||
|
||||
export function clampLines(n: unknown): number {
|
||||
const v = typeof n === "string" ? Number(n) : n;
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
|
||||
return Math.min(2000, Math.max(1, Math.round(v)));
|
||||
}
|
||||
|
||||
export function buildTailCommand(path: string, lines: number): string {
|
||||
// Keep stderr intact so execElevated can detect a permission error and
|
||||
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
|
||||
// empty log with no chance to retry under sudo.
|
||||
return `tail -n ${lines} ${shellSingleQuote(path)}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => `/var/log/${name}`);
|
||||
return { common: COMMON_LOGS, files: found };
|
||||
}),
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 {
|
||||
buildListUpgradableCommand,
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
type PackageAction,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export function registerPackageRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
const { stdout } = await execCommand(client, cmd, 60000);
|
||||
return {
|
||||
pkg: platform.pkg,
|
||||
upgradable: parseUpgradable(platform.pkg, stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/packages/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
const { action, pkg: name } = req.body as {
|
||||
action?: PackageAction;
|
||||
pkg?: string;
|
||||
};
|
||||
if (
|
||||
action !== "upgrade-all" &&
|
||||
action !== "install" &&
|
||||
action !== "upgrade"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
if (action !== "upgrade-all" && !isValidPackageName(name)) {
|
||||
throw new ManagerInputError("Invalid package name");
|
||||
}
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildPackageActionCommand(platform.pkg, action, name);
|
||||
if (!cmd) throw new ManagerInputError("No supported package manager");
|
||||
// Package operations can be slow; allow up to 10 minutes.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 600000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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 {
|
||||
isValidPid,
|
||||
isValidSignal,
|
||||
type Signal,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
user: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
rss: number;
|
||||
stat: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
const LIST_PROCESSES_CMD =
|
||||
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
|
||||
|
||||
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
|
||||
export function parseProcessList(output: string): ProcessRow[] {
|
||||
const rows: ProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(
|
||||
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
|
||||
);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
ppid: Number(m[2]),
|
||||
user: m[3],
|
||||
cpu: Number(m[4]),
|
||||
mem: Number(m[5]),
|
||||
rss: Number(m[6]),
|
||||
stat: m[7],
|
||||
command: m[8],
|
||||
args: m[9],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildKillCommand(pid: number, signal: Signal): string {
|
||||
return `kill -${signal} ${pid}`;
|
||||
}
|
||||
|
||||
export function registerProcessRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}:
|
||||
* get:
|
||||
* summary: List processes (rich, sortable, filterable client-side)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: Process list. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}/signal:
|
||||
* post:
|
||||
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* pid: { type: integer }
|
||||
* signal: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Signal result. }
|
||||
* 400: { description: Invalid pid or signal. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/processes/:id/signal",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
const { pid, signal } = req.body as {
|
||||
pid?: number;
|
||||
signal?: string;
|
||||
};
|
||||
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
|
||||
if (!isValidSignal(signal))
|
||||
throw new ManagerInputError("Invalid signal");
|
||||
const cmd = buildKillCommand(Number(pid), signal);
|
||||
// Try unprivileged first; elevate only if the process isn't owned.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
usedSudo: result.usedSudo,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { ElevationError } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type { 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") {
|
||||
super(message);
|
||||
this.name = "AccessDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagerInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ManagerInputError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
|
||||
* given access level, and maps known errors to clean HTTP responses.
|
||||
*/
|
||||
export function managerHandler(
|
||||
runOnHost: RunOnHost,
|
||||
level: HostAction,
|
||||
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) =>
|
||||
fn(client, host, req),
|
||||
);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagerInputError) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof AccessDeniedError) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof ElevationError) {
|
||||
return res.status(403).json({ error: error.message, code: error.code });
|
||||
}
|
||||
statsLogger.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"),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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 {
|
||||
isValidSystemdUnit,
|
||||
isValidServiceAction,
|
||||
type ServiceAction,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemdService {
|
||||
unit: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const LIST_SERVICES_CMD =
|
||||
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
|
||||
|
||||
/** Parse `systemctl list-units --plain` output into structured rows. */
|
||||
export function parseServiceList(output: string): SystemdService[] {
|
||||
const services: SystemdService[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const [unit, load, active, sub, ...rest] = parts;
|
||||
if (!unit.endsWith(".service")) continue;
|
||||
services.push({
|
||||
unit,
|
||||
load,
|
||||
active,
|
||||
sub,
|
||||
description: rest.join(" "),
|
||||
});
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
export function buildServiceActionCommand(
|
||||
unit: string,
|
||||
action: ServiceAction,
|
||||
): string {
|
||||
return `systemctl ${action} ${unit}`;
|
||||
}
|
||||
|
||||
export function registerServiceRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}:
|
||||
* get:
|
||||
* summary: List systemd services
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: List of services. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}/action:
|
||||
* post:
|
||||
* summary: Start/stop/restart/enable/disable a systemd service
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* unit: { type: string }
|
||||
* action: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Action result. }
|
||||
* 400: { description: Invalid unit or action. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/services/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
const { unit, action } = req.body as {
|
||||
unit?: string;
|
||||
action?: string;
|
||||
};
|
||||
if (!isValidSystemdUnit(unit)) {
|
||||
throw new ManagerInputError("Invalid unit name");
|
||||
}
|
||||
if (!isValidServiceAction(action)) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
buildServiceActionCommand(unit, action),
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
// ─── Top by memory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemProcessRow {
|
||||
pid: number;
|
||||
user: string;
|
||||
mem: number;
|
||||
rss: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const TOP_MEM_CMD =
|
||||
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
|
||||
|
||||
export function parseTopMemory(output: string): MemProcessRow[] {
|
||||
const rows: MemProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
user: m[2],
|
||||
mem: Number(m[3]),
|
||||
rss: Number(m[4]),
|
||||
command: m[5],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Systemd timers ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimerRow {
|
||||
next: string;
|
||||
left: string;
|
||||
last: string;
|
||||
unit: string;
|
||||
activates: string;
|
||||
}
|
||||
|
||||
const TIMERS_CMD =
|
||||
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
|
||||
|
||||
export function parseTimers(output: string): TimerRow[] {
|
||||
const rows: TimerRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("NEXT")) continue;
|
||||
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
|
||||
// the trailing UNIT + ACTIVATES which always end the line.
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
const activates = parts[parts.length - 1];
|
||||
const unit = parts[parts.length - 2];
|
||||
if (!unit.endsWith(".timer")) continue;
|
||||
rows.push({
|
||||
next: parts.slice(0, 3).join(" "),
|
||||
left: "",
|
||||
last: "",
|
||||
unit,
|
||||
activates,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
|
||||
|
||||
export interface MountUsage {
|
||||
filesystem: string;
|
||||
sizeKb: number;
|
||||
usedKb: number;
|
||||
availKb: number;
|
||||
usePct: number;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
|
||||
|
||||
export function parseDfMounts(output: string): MountUsage[] {
|
||||
const mounts: MountUsage[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
const fs = m[1];
|
||||
// Skip pseudo/virtual filesystems that clutter the view.
|
||||
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
|
||||
mounts.push({
|
||||
filesystem: fs,
|
||||
sizeKb: Number(m[2]),
|
||||
usedKb: Number(m[3]),
|
||||
availKb: Number(m[4]),
|
||||
usePct: Number(m[5]),
|
||||
mount: m[6],
|
||||
});
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
||||
export function registerSimpleReadRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
import {
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isAllowedPath,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import { detectPlatform } from "../../../../src/backend/hosts/metrics-shared/platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export type AcmeClient = "certbot" | "acme.sh";
|
||||
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
|
||||
|
||||
export interface CertInfo {
|
||||
client: AcmeClient | "other";
|
||||
name: string;
|
||||
domains: string[];
|
||||
expiry: string | null;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
|
||||
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
|
||||
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
|
||||
|
||||
/** Parse `certbot certificates` output. */
|
||||
export function parseCertbotCertificates(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
let current: CertInfo | null = null;
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
|
||||
if (nameM) {
|
||||
if (current) certs.push(current);
|
||||
current = {
|
||||
client: "certbot",
|
||||
name: nameM[1],
|
||||
domains: [],
|
||||
expiry: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const domM = line.match(/^Domains:\s+(.+)$/);
|
||||
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
|
||||
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
|
||||
if (expM) current.expiry = expM[1];
|
||||
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
|
||||
if (pathM) current.path = pathM[1];
|
||||
}
|
||||
if (current) certs.push(current);
|
||||
return certs;
|
||||
}
|
||||
|
||||
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
|
||||
export function parseAcmeShList(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
const lines = output.split("\n").filter((l) => l.trim());
|
||||
if (lines.length < 2) return certs;
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line
|
||||
.split(/\s{2,}|\t/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (cols.length < 1) continue;
|
||||
certs.push({
|
||||
client: "acme.sh",
|
||||
name: cols[0],
|
||||
domains: [cols[0]],
|
||||
expiry: cols[cols.length - 1] || null,
|
||||
});
|
||||
}
|
||||
return certs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
|
||||
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
|
||||
*/
|
||||
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
|
||||
export function isValidCertName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
export interface IssueRequest {
|
||||
client: AcmeClient;
|
||||
domains: string[];
|
||||
challenge: ChallengeType;
|
||||
webroot?: string;
|
||||
dnsProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the certificate issuance command for the chosen client/challenge.
|
||||
* DNS-01 provider credentials are expected to already be present in the
|
||||
* environment / provider config file; we never put secrets in argv.
|
||||
*/
|
||||
export function buildIssueCommand(req: IssueRequest): string {
|
||||
const domains = req.domains;
|
||||
if (req.client === "certbot") {
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
|
||||
}
|
||||
// acme.sh
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
|
||||
}
|
||||
|
||||
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot renew${dryRun ? " --dry-run" : ""}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
|
||||
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
|
||||
* it from management. `name` is the certbot cert name or the acme.sh domain.
|
||||
*/
|
||||
export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
|
||||
name,
|
||||
)} --delete-after-revoke`;
|
||||
}
|
||||
const d = shellSingleQuote(name);
|
||||
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
||||
}
|
||||
|
||||
export function registerSslRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
const r = await execElevated(
|
||||
client,
|
||||
CERTBOT_LIST_CMD,
|
||||
host.sudoPassword,
|
||||
).catch(() => null);
|
||||
if (r) certs.push(...parseCertbotCertificates(r.stdout));
|
||||
}
|
||||
if (platform.hasAcmeSh) {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
ACMESH_LIST_CMD,
|
||||
15000,
|
||||
).catch(() => ({ stdout: "" }) as { stdout: string });
|
||||
certs.push(...parseAcmeShList(stdout));
|
||||
}
|
||||
return {
|
||||
clients: {
|
||||
certbot: platform.hasCertbot,
|
||||
acmeSh: platform.hasAcmeSh,
|
||||
},
|
||||
certs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/ssl/{id}/revoke:
|
||||
* post:
|
||||
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* client: { type: string, enum: [certbot, acme.sh] }
|
||||
* name: { type: string, description: certbot cert name or acme.sh domain }
|
||||
* responses:
|
||||
* 200: { description: Revoke result. }
|
||||
* 400: { description: Invalid client or certificate name. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
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),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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 type { ManagerRoutesDeps } from "./types.js";
|
||||
import { isValidTailscaleAction } from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
|
||||
export interface TailscalePeer {
|
||||
hostname: string;
|
||||
tailscaleIPs: string[];
|
||||
online: boolean;
|
||||
isExitNode: boolean;
|
||||
}
|
||||
|
||||
export interface TailscaleData {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
tailscaleIPs: string[];
|
||||
hostname: string | null;
|
||||
peers: TailscalePeer[];
|
||||
exitNodeInUse: boolean;
|
||||
}
|
||||
|
||||
const PROBE_CMD = [
|
||||
"command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0",
|
||||
"tailscale status --json 2>/dev/null",
|
||||
].join("; ");
|
||||
|
||||
export function parseTailscaleData(output: string): TailscaleData {
|
||||
const notInstalled: TailscaleData = {
|
||||
installed: false,
|
||||
running: false,
|
||||
tailscaleIPs: [],
|
||||
hostname: null,
|
||||
peers: [],
|
||||
exitNodeInUse: false,
|
||||
};
|
||||
|
||||
if (output.includes("ts_installed=0")) return notInstalled;
|
||||
|
||||
// Strip the installation probe line to get the raw JSON
|
||||
const lines = output.split("\n");
|
||||
const jsonLines = lines.filter(
|
||||
(l) => !l.startsWith("ts_installed=") && l.trim() !== "",
|
||||
);
|
||||
const jsonStr = jsonLines.join("\n");
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr) as {
|
||||
BackendState?: string;
|
||||
Self?: { HostName?: string; TailscaleIPs?: string[] };
|
||||
Peer?: Record<
|
||||
string,
|
||||
{
|
||||
HostName?: string;
|
||||
TailscaleIPs?: string[];
|
||||
Online?: boolean;
|
||||
ExitNode?: boolean;
|
||||
}
|
||||
>;
|
||||
CurrentExitNode?: string;
|
||||
};
|
||||
|
||||
const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map(
|
||||
(p) => ({
|
||||
hostname: p.HostName ?? "",
|
||||
tailscaleIPs: p.TailscaleIPs ?? [],
|
||||
online: p.Online ?? false,
|
||||
isExitNode: p.ExitNode ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
running: parsed.BackendState === "Running",
|
||||
tailscaleIPs: parsed.Self?.TailscaleIPs ?? [],
|
||||
hostname: parsed.Self?.HostName ?? null,
|
||||
peers,
|
||||
exitNodeInUse:
|
||||
typeof parsed.CurrentExitNode === "string" &&
|
||||
parsed.CurrentExitNode !== "",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
installed: true,
|
||||
running: false,
|
||||
tailscaleIPs: [],
|
||||
hostname: null,
|
||||
peers: [],
|
||||
exitNodeInUse: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTailscaleRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/tailscale/{id}:
|
||||
* get:
|
||||
* summary: Get Tailscale status and IPs
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale installation status, running state, IPs, and peer count.
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/tailscale/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "tailscale_read", async (client) => {
|
||||
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
|
||||
return parseTailscaleData(stdout);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/tailscale/{id}/action:
|
||||
* post:
|
||||
* summary: Connect or disconnect Tailscale
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [up, down]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Action result.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/tailscale/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"tailscale_action",
|
||||
async (client, host, req) => {
|
||||
const { action } = req.body as { action: unknown };
|
||||
if (!isValidTailscaleAction(action)) {
|
||||
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
`tailscale ${action}`,
|
||||
host.sudoPassword,
|
||||
{ forceSudo: false, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout + result.stderr).trim(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { RequestHandler } from "express";
|
||||
import type { HostAction } from "../../../../src/backend/utils/permission-manager.js";
|
||||
|
||||
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
||||
export interface ManagerHost {
|
||||
id: number;
|
||||
userId: 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.
|
||||
*/
|
||||
export type RunOnHost = <T>(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
level: HostAction,
|
||||
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export interface ManagerRoutesDeps {
|
||||
validateHostId: RequestHandler;
|
||||
runOnHost: RunOnHost;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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 {
|
||||
isValidUsername,
|
||||
isValidGroupName,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemUser {
|
||||
name: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
shell: string;
|
||||
}
|
||||
|
||||
export interface SystemGroup {
|
||||
name: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
// Human users only (uid >= 1000, excluding nobody at 65534).
|
||||
const READ_USERS_CMD = "getent passwd 2>/dev/null";
|
||||
const READ_GROUPS_CMD = "getent group 2>/dev/null";
|
||||
const READ_SUDOERS_CMD = "getent group sudo wheel 2>/dev/null";
|
||||
|
||||
export function parsePasswd(output: string): SystemUser[] {
|
||||
const users: SystemUser[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 7) continue;
|
||||
const uid = Number(parts[2]);
|
||||
if (!Number.isFinite(uid)) continue;
|
||||
if (uid < 1000 || uid === 65534) continue;
|
||||
users.push({
|
||||
name: parts[0],
|
||||
uid,
|
||||
gid: Number(parts[3]),
|
||||
home: parts[5],
|
||||
shell: parts[6],
|
||||
});
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
export function parseGroups(output: string): SystemGroup[] {
|
||||
const groups: SystemGroup[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) continue;
|
||||
groups.push({
|
||||
name: parts[0],
|
||||
gid: Number(parts[2]),
|
||||
members: parts[3].split(",").filter(Boolean),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function parseSudoers(output: string): string[] {
|
||||
const members = new Set<string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) continue;
|
||||
parts[3]
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.forEach((m) => members.add(m));
|
||||
}
|
||||
return [...members];
|
||||
}
|
||||
|
||||
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
|
||||
|
||||
export function registerUserRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/users/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "connect", "users_list", async (client) => {
|
||||
const [passwd, groups, sudoers] = await Promise.all([
|
||||
execCommand(client, READ_USERS_CMD, 15000),
|
||||
execCommand(client, READ_GROUPS_CMD, 15000),
|
||||
execCommand(client, READ_SUDOERS_CMD, 15000),
|
||||
]);
|
||||
return {
|
||||
users: parsePasswd(passwd.stdout),
|
||||
groups: parseGroups(groups.stdout),
|
||||
sudoers: parseSudoers(sudoers.stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/users/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"users_action",
|
||||
async (client, host, req) => {
|
||||
const { action, username, group } = req.body as {
|
||||
action?: UserAction;
|
||||
username?: string;
|
||||
group?: string;
|
||||
};
|
||||
if (!isValidUsername(username))
|
||||
throw new ManagerInputError("Invalid username");
|
||||
|
||||
// Never modify/delete the user we're connected as, or root.
|
||||
const who = (await execCommand(client, "id -un", 8000)).stdout.trim();
|
||||
if (username === who || username === "root") {
|
||||
throw new ManagerInputError(
|
||||
"Refusing to modify the connected user or root",
|
||||
);
|
||||
}
|
||||
|
||||
let cmd: string;
|
||||
switch (action) {
|
||||
case "create":
|
||||
cmd = `useradd -m ${username}`;
|
||||
break;
|
||||
case "delete":
|
||||
cmd = `userdel -r ${username}`;
|
||||
break;
|
||||
case "addToGroup":
|
||||
case "removeFromGroup":
|
||||
if (!isValidGroupName(group))
|
||||
throw new ManagerInputError("Invalid group");
|
||||
cmd =
|
||||
action === "addToGroup"
|
||||
? `usermod -aG ${group} ${username}`
|
||||
: `gpasswd -d ${username} ${group}`;
|
||||
break;
|
||||
default:
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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 {
|
||||
isValidWireGuardInterface,
|
||||
isValidWireGuardAction,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
||||
|
||||
export interface WireGuardPeer {
|
||||
publicKey: string;
|
||||
endpoint: string | null;
|
||||
allowedIPs: string[];
|
||||
latestHandshake: number | null;
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
}
|
||||
|
||||
export interface WireGuardInterface {
|
||||
name: string;
|
||||
publicKey: string | null;
|
||||
listenPort: number | null;
|
||||
up: boolean;
|
||||
peers: WireGuardPeer[];
|
||||
}
|
||||
|
||||
export interface WireGuardData {
|
||||
installed: boolean;
|
||||
interfaces: WireGuardInterface[];
|
||||
}
|
||||
|
||||
const PROBE_CMD = [
|
||||
"command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0",
|
||||
"ip link show type wireguard 2>/dev/null",
|
||||
"echo __DUMP__",
|
||||
"wg show all dump 2>/dev/null",
|
||||
].join("; ");
|
||||
|
||||
export function parseWireGuardData(output: string): WireGuardData {
|
||||
if (output.includes("wg_installed=0")) {
|
||||
return { installed: false, interfaces: [] };
|
||||
}
|
||||
|
||||
const dumpIdx = output.indexOf("__DUMP__");
|
||||
const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : "";
|
||||
const dumpPart =
|
||||
dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : "";
|
||||
|
||||
// Collect up interface names from `ip link show type wireguard`
|
||||
const upSet = new Set<string>();
|
||||
for (const line of ipLinkPart.split("\n")) {
|
||||
const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/);
|
||||
if (m) upSet.add(m[1]);
|
||||
}
|
||||
|
||||
const ifaceMap = new Map<string, WireGuardInterface>();
|
||||
|
||||
for (const raw of dumpPart.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const cols = line.split("\t");
|
||||
|
||||
if (cols.length === 5) {
|
||||
// Interface row: iface private_key public_key listen_port fwmark
|
||||
const [name, , publicKey, listenPortStr] = cols;
|
||||
if (!name) continue;
|
||||
ifaceMap.set(name, {
|
||||
name,
|
||||
publicKey: publicKey && publicKey !== "(none)" ? publicKey : null,
|
||||
listenPort:
|
||||
listenPortStr && listenPortStr !== "(none)"
|
||||
? Number(listenPortStr)
|
||||
: null,
|
||||
up: upSet.has(name),
|
||||
peers: [],
|
||||
});
|
||||
} else if (cols.length === 9) {
|
||||
// Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive
|
||||
const [
|
||||
name,
|
||||
publicKey,
|
||||
,
|
||||
endpoint,
|
||||
allowedIPsStr,
|
||||
handshakeStr,
|
||||
rxStr,
|
||||
txStr,
|
||||
] = cols;
|
||||
if (!name) continue;
|
||||
|
||||
if (!ifaceMap.has(name)) {
|
||||
ifaceMap.set(name, {
|
||||
name,
|
||||
publicKey: null,
|
||||
listenPort: null,
|
||||
up: upSet.has(name),
|
||||
peers: [],
|
||||
});
|
||||
}
|
||||
|
||||
const handshake = Number(handshakeStr);
|
||||
ifaceMap.get(name)!.peers.push({
|
||||
publicKey: publicKey ?? "",
|
||||
endpoint: endpoint && endpoint !== "(none)" ? endpoint : null,
|
||||
allowedIPs:
|
||||
allowedIPsStr && allowedIPsStr !== "(none)"
|
||||
? allowedIPsStr.split(",").map((s) => s.trim())
|
||||
: [],
|
||||
latestHandshake: handshake > 0 ? handshake : null,
|
||||
rxBytes: Number(rxStr) || 0,
|
||||
txBytes: Number(txStr) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { installed: true, interfaces: Array.from(ifaceMap.values()) };
|
||||
}
|
||||
|
||||
export function registerWireGuardRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}:
|
||||
* get:
|
||||
* summary: Get WireGuard interfaces and peers
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: WireGuard installation status, interfaces, and peer details.
|
||||
*/
|
||||
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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}/action:
|
||||
* post:
|
||||
* summary: Bring a WireGuard interface up or down
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* interface:
|
||||
* type: string
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [up, down]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Action result.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/wireguard/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"connect",
|
||||
"wireguard_action",
|
||||
async (client, host, req) => {
|
||||
const { interface: iface, action } = req.body as {
|
||||
interface: unknown;
|
||||
action: unknown;
|
||||
};
|
||||
if (!isValidWireGuardInterface(iface)) {
|
||||
throw new ManagerInputError("Invalid WireGuard interface name");
|
||||
}
|
||||
if (!isValidWireGuardAction(action)) {
|
||||
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
`wg-quick ${action} ${iface}`,
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout + result.stderr).trim(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
import type { Client, ConnectConfig } from "ssh2";
|
||||
import { statsLogger } from "../../../src/backend/utils/logger.js";
|
||||
|
||||
export interface MetricsSession {
|
||||
client: Client;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: Client;
|
||||
finish: (responses: string[]) => void;
|
||||
config: ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
}
|
||||
|
||||
export interface MetricsViewer {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
export const metricsSessions: Record<string, MetricsSession> = {};
|
||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
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 {
|
||||
return `${userId}:${hostId}`;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
toFixedNum,
|
||||
type HostPlatform,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
/**
|
||||
* CPU percent is derived from the delta against the previous poll's
|
||||
* /proc/stat sample rather than a same-poll double-read, so a poll no longer
|
||||
* has to block for a fixed settle window. Keyed by host.
|
||||
*/
|
||||
const previousStat = new Map<
|
||||
number,
|
||||
{ total: number; idle: number; timestamp: number }
|
||||
>();
|
||||
|
||||
export function clearCpuSampleCache(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
previousStat.clear();
|
||||
return;
|
||||
}
|
||||
previousStat.delete(hostId);
|
||||
}
|
||||
|
||||
export function parseCpuLine(
|
||||
cpuLine: string,
|
||||
): { total: number; idle: number } | undefined {
|
||||
const parts = cpuLine.trim().split(/\s+/);
|
||||
if (parts[0] !== "cpu") return undefined;
|
||||
const nums = parts
|
||||
.slice(1)
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (nums.length < 4) return undefined;
|
||||
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
||||
const total = nums.reduce((a, b) => a + b, 0);
|
||||
return { total, idle };
|
||||
}
|
||||
|
||||
// Parses macOS `top -l 1 -n 0` output, e.g.
|
||||
// "CPU usage: 12.34% user, 5.67% sys, 81.99% idle"
|
||||
export function parseDarwinCpuUsageLine(
|
||||
line: string,
|
||||
): { percent: number } | undefined {
|
||||
const match = line.match(
|
||||
/([\d.]+)%\s*user,\s*([\d.]+)%\s*sys,\s*([\d.]+)%\s*idle/i,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
const user = Number(match[1]);
|
||||
const sys = Number(match[2]);
|
||||
if (!Number.isFinite(user) || !Number.isFinite(sys)) return undefined;
|
||||
return { percent: Math.max(0, Math.min(100, user + sys)) };
|
||||
}
|
||||
|
||||
async function collectDarwinCpuMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const [topOut, coresOut, loadOut] = await Promise.race([
|
||||
Promise.all([
|
||||
execCommand(client, "top -l 1 -n 0"),
|
||||
execCommand(client, "sysctl -n hw.ncpu"),
|
||||
execCommand(client, "sysctl -n vm.loadavg"),
|
||||
]),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("CPU metrics collection timeout")),
|
||||
25000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const usageLine = topOut.stdout
|
||||
.split("\n")
|
||||
.find((l) => l.includes("CPU usage:"));
|
||||
const parsed = usageLine ? parseDarwinCpuUsageLine(usageLine) : undefined;
|
||||
if (parsed) cpuPercent = parsed.percent;
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
|
||||
// sysctl -n vm.loadavg -> "{ 1.23 1.45 1.67 }"
|
||||
const loadMatch = loadOut.stdout.match(/([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
||||
if (loadMatch) {
|
||||
loadTriplet = [
|
||||
Number(loadMatch[1]),
|
||||
Number(loadMatch[2]),
|
||||
Number(loadMatch[3]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: loadTriplet,
|
||||
};
|
||||
}
|
||||
|
||||
// No POSIX-style load average exists on Windows, so `load` always stays null.
|
||||
async function collectWindowsCpuMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$cpu=(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $cores=(Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors; [PSCustomObject]@{cpu=$cpu; cores=$cores} | ConvertTo-Json -Compress",
|
||||
25000,
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const cpuNum = Number(parsed?.cpu);
|
||||
const coresNum = Number(parsed?.cores);
|
||||
cpuPercent = Number.isFinite(cpuNum)
|
||||
? Math.max(0, Math.min(100, cpuNum))
|
||||
: null;
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectCpuMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
hostId?: number,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinCpuMetrics(client);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsCpuMetrics(client);
|
||||
}
|
||||
|
||||
let cpuPercent: number | null = null;
|
||||
let cores: number | null = null;
|
||||
let loadTriplet: [number, number, number] | null = null;
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const [statOut, loadAvgOut, coresOut] = await Promise.race([
|
||||
Promise.all([
|
||||
execCommand(client, "cat /proc/stat"),
|
||||
execCommand(client, "cat /proc/loadavg"),
|
||||
execCommand(
|
||||
client,
|
||||
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
|
||||
),
|
||||
]),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("CPU metrics collection timeout")),
|
||||
25000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const cpuLine = (
|
||||
statOut.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
|
||||
).trim();
|
||||
const current = parseCpuLine(cpuLine);
|
||||
const previous =
|
||||
hostId !== undefined ? previousStat.get(hostId) : undefined;
|
||||
if (current && previous) {
|
||||
const totalDiff = current.total - previous.total;
|
||||
const idleDiff = current.idle - previous.idle;
|
||||
const used = totalDiff - idleDiff;
|
||||
if (totalDiff > 0)
|
||||
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
|
||||
}
|
||||
if (current && hostId !== undefined) {
|
||||
previousStat.set(hostId, { ...current, timestamp: readAt });
|
||||
}
|
||||
|
||||
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
|
||||
if (laParts.length >= 3) {
|
||||
loadTriplet = [
|
||||
Number(laParts[0]),
|
||||
Number(laParts[1]),
|
||||
Number(laParts[2]),
|
||||
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
}
|
||||
|
||||
const coresNum = Number((coresOut.stdout || "").trim());
|
||||
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
|
||||
} catch {
|
||||
cpuPercent = null;
|
||||
loadTriplet = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(cpuPercent, 0),
|
||||
cores,
|
||||
load: loadTriplet,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
toFixedNum,
|
||||
type HostPlatform,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
|
||||
|
||||
export interface DfRow {
|
||||
filesystem: string;
|
||||
type: string;
|
||||
mount: string;
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
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 MonitoredMount {
|
||||
path: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
// Parses `df -T -P`-style output: Filesystem, Type, then the size columns,
|
||||
// with Mounted-on last. The Type column (e.g. ext4, nfs4, cifs) is what lets
|
||||
// callers filter network shares out by filesystem type rather than guessing
|
||||
// from the source path.
|
||||
export function parseDfLines(output: string): DfRow[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
return {
|
||||
filesystem: parts[0] || "",
|
||||
type: parts[1] || "",
|
||||
mount: parts[6] || "",
|
||||
parts,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
|
||||
}
|
||||
|
||||
// Finds the index of the most-utilized real filesystem in a GNU `df -TkP`-style
|
||||
// row set (parts[2] = total KB, parts[3] = used KB), so a nearly-full
|
||||
// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
|
||||
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
||||
index: number;
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
} {
|
||||
let worstIndex = -1;
|
||||
let worstUsedBytes = -1;
|
||||
let worstTotalBytes = 0;
|
||||
|
||||
bytesRows.forEach((row, index) => {
|
||||
const totalBytes = Number(row.parts[2]);
|
||||
const usedBytes = Number(row.parts[3]);
|
||||
if (
|
||||
!Number.isFinite(totalBytes) ||
|
||||
!Number.isFinite(usedBytes) ||
|
||||
totalBytes <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const usedRatio = usedBytes / totalBytes;
|
||||
const worstRatio =
|
||||
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
||||
if (usedRatio > worstRatio) {
|
||||
worstIndex = index;
|
||||
worstUsedBytes = usedBytes;
|
||||
worstTotalBytes = totalBytes;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
index: worstIndex,
|
||||
usedBytes: worstUsedBytes,
|
||||
totalBytes: worstTotalBytes,
|
||||
};
|
||||
}
|
||||
|
||||
// Merges a GNU `df -TkP` size-row set (parts[2..4] in `blockSizeBytes` units)
|
||||
// and a `df -hT -P` human-row set into one filesystem list. Byte rows drive
|
||||
// the maths; human rows only supply the display strings, matched by mount
|
||||
// point so a mismatched row count can't shift the columns.
|
||||
export function buildFilesystemList(
|
||||
bytesRows: DfRow[],
|
||||
humanRows: DfRow[],
|
||||
blockSizeBytes = 1,
|
||||
): DiskFilesystem[] {
|
||||
const aligned = humanRows.length === bytesRows.length;
|
||||
|
||||
return bytesRows
|
||||
.map((row, index) => {
|
||||
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
|
||||
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
|
||||
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
|
||||
|
||||
const humanRow = aligned
|
||||
? humanRows[index]
|
||||
: humanRows.find((h) => h.mount === row.mount);
|
||||
|
||||
const percent = Number.isFinite(usedBytes)
|
||||
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
filesystem: row.filesystem,
|
||||
type: row.type,
|
||||
mount: row.mount,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: humanRow?.parts[3] || null,
|
||||
totalHuman: humanRow?.parts[2] || null,
|
||||
availableHuman: humanRow?.parts[4] || null,
|
||||
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
|
||||
totalBytes,
|
||||
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
|
||||
};
|
||||
})
|
||||
.filter((fs): fs is DiskFilesystem => fs !== null);
|
||||
}
|
||||
|
||||
// The headline disk figure should be the root filesystem - that is what users
|
||||
// mean by "the server's disk". Only when there is no root mount (containers,
|
||||
// chroots) do we fall back to the most-utilized mount.
|
||||
export function selectPrimaryFilesystem(
|
||||
filesystems: DiskFilesystem[],
|
||||
): DiskFilesystem | null {
|
||||
if (filesystems.length === 0) return null;
|
||||
|
||||
const root = filesystems.find((fs) => fs.mount === "/");
|
||||
if (root) return root;
|
||||
|
||||
let best = filesystems[0];
|
||||
for (const fs of filesystems) {
|
||||
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
|
||||
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
|
||||
if (ratio > bestRatio) best = fs;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Excluded mounts are user-configured per host: an exact mount-path match
|
||||
// (e.g. "/mnt/nas") or a filesystem-type substring match (e.g. "nfs" matches
|
||||
// nfs/nfs4, "cifs" matches cifs/smb3), so network shares can be dropped from
|
||||
// the headline percent and the filesystem list without hiding local disks.
|
||||
export function filterExcludedFilesystems(
|
||||
filesystems: DiskFilesystem[],
|
||||
excludedMounts?: string[] | null,
|
||||
): DiskFilesystem[] {
|
||||
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
|
||||
|
||||
const normalized = excludedMounts
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (normalized.length === 0) return filesystems;
|
||||
|
||||
return filesystems.filter((fs) => {
|
||||
const mount = fs.mount.toLowerCase();
|
||||
const type = fs.type.toLowerCase();
|
||||
return !normalized.some(
|
||||
(entry) => mount === entry || (type && type.includes(entry)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeMonitoredFilesystems(
|
||||
filesystems: DiskFilesystem[],
|
||||
monitored: MonitoredMount[],
|
||||
customFilesystems: Array<DiskFilesystem | null>,
|
||||
): DiskFilesystem[] {
|
||||
const result = [...filesystems];
|
||||
monitored.forEach((entry, index) => {
|
||||
const path = entry.path.trim();
|
||||
const custom = customFilesystems[index];
|
||||
if (!path || !custom) return;
|
||||
|
||||
const existing = result.find((fs) => fs.mount === path);
|
||||
if (existing) {
|
||||
existing.label = entry.label?.trim() || undefined;
|
||||
return;
|
||||
}
|
||||
result.push({
|
||||
...custom,
|
||||
mount: path,
|
||||
label: entry.label?.trim() || undefined,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function humanizeBytes(bytes: number): string {
|
||||
const units = ["B", "K", "M", "G", "T", "P"];
|
||||
let value = Math.max(0, bytes);
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const rounded =
|
||||
unitIndex > 0 && value < 10 ? value.toFixed(1) : Math.round(value);
|
||||
return `${rounded}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
// BSD/macOS `df -T` takes a filesystem-type argument to *filter* by, unlike
|
||||
// GNU `df -T` which *prints* a type column - so macOS needs its own command
|
||||
// (`df -Pk`, no type column) and gets the type separately from `mount`.
|
||||
const DARWIN_SKIP_MOUNT_RE =
|
||||
/^\/System\/Volumes\/(VM|Preboot|Update|xarts|iSCPreboot|Hardware)(\/|$)/;
|
||||
const DARWIN_SKIP_TYPE_RE = /^(devfs|autofs)$/;
|
||||
|
||||
export function parseDarwinMountTypes(output: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of output.split("\n")) {
|
||||
// Device names can contain spaces (e.g. autofs's "map auto_home"), so
|
||||
// match the literal " on " / " (" separators rather than a single token.
|
||||
const match = line.match(/^.+? on (.+?) \(([^,)]+)/);
|
||||
if (match) map.set(match[1], match[2].trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function parseDarwinDfRows(
|
||||
output: string,
|
||||
typeByMount: Map<string, string>,
|
||||
): DiskFilesystem[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return null;
|
||||
|
||||
const filesystem = parts[0];
|
||||
const totalKb = Number(parts[1]);
|
||||
const usedKb = Number(parts[2]);
|
||||
const availableKb = Number(parts[3]);
|
||||
const mount = parts.slice(5).join(" ");
|
||||
if (!Number.isFinite(totalKb) || totalKb <= 0) return null;
|
||||
if (DARWIN_SKIP_MOUNT_RE.test(mount)) return null;
|
||||
|
||||
const type = typeByMount.get(mount) || "";
|
||||
if (DARWIN_SKIP_TYPE_RE.test(type)) return null;
|
||||
|
||||
const totalBytes = totalKb * 1024;
|
||||
const usedBytes = Number.isFinite(usedKb) ? usedKb * 1024 : null;
|
||||
const availableBytes = Number.isFinite(availableKb)
|
||||
? availableKb * 1024
|
||||
: null;
|
||||
const percent =
|
||||
usedBytes !== null
|
||||
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
filesystem,
|
||||
type,
|
||||
mount,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: usedBytes !== null ? humanizeBytes(usedBytes) : null,
|
||||
totalHuman: humanizeBytes(totalBytes),
|
||||
availableHuman:
|
||||
availableBytes !== null ? humanizeBytes(availableBytes) : null,
|
||||
usedBytes,
|
||||
totalBytes,
|
||||
availableBytes,
|
||||
};
|
||||
})
|
||||
.filter((fs): fs is DiskFilesystem => fs !== null);
|
||||
}
|
||||
|
||||
async function collectDarwinDiskMetrics(
|
||||
client: Client,
|
||||
excludedMounts?: string[] | null,
|
||||
monitoredMounts?: MonitoredMount[] | null,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems: DiskFilesystem[];
|
||||
}> {
|
||||
try {
|
||||
const [dfOut, mountOut] = await Promise.all([
|
||||
execCommand(client, "df -Pk | tail -n +2"),
|
||||
execCommand(client, "mount"),
|
||||
]);
|
||||
|
||||
const typeByMount = parseDarwinMountTypes(mountOut.stdout);
|
||||
let detected = parseDarwinDfRows(dfOut.stdout, typeByMount);
|
||||
|
||||
const monitored = (monitoredMounts ?? []).filter((entry) =>
|
||||
Boolean(entry.path.trim()),
|
||||
);
|
||||
if (monitored.length > 0) {
|
||||
const customFilesystems = await Promise.all(
|
||||
monitored.map(async (entry) => {
|
||||
const path = shellQuote(entry.path.trim());
|
||||
try {
|
||||
const custom = await execCommand(
|
||||
client,
|
||||
`df -Pk -- ${path} | tail -n +2`,
|
||||
);
|
||||
return parseDarwinDfRows(custom.stdout, typeByMount)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
detected = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
monitored,
|
||||
customFilesystems,
|
||||
);
|
||||
}
|
||||
|
||||
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
|
||||
const primary = selectPrimaryFilesystem(filesystems);
|
||||
|
||||
return {
|
||||
percent: primary?.percent ?? null,
|
||||
usedHuman: primary?.usedHuman ?? null,
|
||||
totalHuman: primary?.totalHuman ?? null,
|
||||
availableHuman: primary?.availableHuman ?? null,
|
||||
mount: primary?.mount ?? null,
|
||||
filesystems,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface WindowsDiskRow {
|
||||
drive: string;
|
||||
total: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
const WINDOWS_DISK_SCRIPT =
|
||||
'Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {' +
|
||||
" [PSCustomObject]@{drive=$_.DeviceID; total=$_.Size; free=$_.FreeSpace}" +
|
||||
" } | ConvertTo-Json -Compress";
|
||||
|
||||
export function parseWindowsDiskJson(output: string): WindowsDiskRow[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return rows
|
||||
.filter((row): row is Record<string, unknown> => Boolean(row?.drive))
|
||||
.map((row) => ({
|
||||
drive: String(row.drive),
|
||||
total: Number(row.total),
|
||||
free: Number(row.free),
|
||||
}))
|
||||
.filter((row) => Number.isFinite(row.total) && row.total > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function collectWindowsDiskMetrics(
|
||||
client: Client,
|
||||
excludedMounts?: string[] | null,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems: DiskFilesystem[];
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await execPowerShell(client, WINDOWS_DISK_SCRIPT);
|
||||
const rows = parseWindowsDiskJson(stdout);
|
||||
|
||||
const filesystems: DiskFilesystem[] = rows.map((row) => {
|
||||
const usedBytes = row.total - row.free;
|
||||
const percent = Math.max(0, Math.min(100, (usedBytes / row.total) * 100));
|
||||
return {
|
||||
filesystem: row.drive,
|
||||
type: "NTFS",
|
||||
mount: row.drive,
|
||||
percent: toFixedNum(percent, 0),
|
||||
usedHuman: humanizeBytes(usedBytes),
|
||||
totalHuman: humanizeBytes(row.total),
|
||||
availableHuman: humanizeBytes(row.free),
|
||||
usedBytes,
|
||||
totalBytes: row.total,
|
||||
availableBytes: row.free,
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = filterExcludedFilesystems(filesystems, excludedMounts);
|
||||
const primary =
|
||||
filtered.find((fs) => fs.mount.toUpperCase().startsWith("C")) ??
|
||||
filtered[0] ??
|
||||
null;
|
||||
|
||||
return {
|
||||
percent: primary?.percent ?? null,
|
||||
usedHuman: primary?.usedHuman ?? null,
|
||||
totalHuman: primary?.totalHuman ?? null,
|
||||
availableHuman: primary?.availableHuman ?? null,
|
||||
mount: primary?.mount ?? null,
|
||||
filesystems: filtered,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDiskMetrics(
|
||||
client: Client,
|
||||
excludedMounts?: string[] | null,
|
||||
monitoredMounts?: MonitoredMount[] | null,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedHuman: string | null;
|
||||
totalHuman: string | null;
|
||||
availableHuman: string | null;
|
||||
mount: string | null;
|
||||
filesystems: DiskFilesystem[];
|
||||
}> {
|
||||
if (platform === "windows") {
|
||||
return collectWindowsDiskMetrics(client, excludedMounts);
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinDiskMetrics(client, excludedMounts, monitoredMounts);
|
||||
}
|
||||
|
||||
try {
|
||||
const [diskOutHuman, diskOutBytes] = await Promise.all([
|
||||
execCommand(client, "df -hT -P | tail -n +2"),
|
||||
execCommand(client, "df -TkP | tail -n +2"),
|
||||
]);
|
||||
|
||||
const humanRows = parseDfLines(diskOutHuman.stdout);
|
||||
const bytesRows = parseDfLines(diskOutBytes.stdout);
|
||||
let detected = buildFilesystemList(bytesRows, humanRows, 1024);
|
||||
const monitored = (monitoredMounts ?? []).filter((entry) =>
|
||||
Boolean(entry.path.trim()),
|
||||
);
|
||||
if (monitored.length > 0) {
|
||||
const customFilesystems = await Promise.all(
|
||||
monitored.map(async (entry) => {
|
||||
const path = shellQuote(entry.path.trim());
|
||||
try {
|
||||
const [customHuman, customBytes] = await Promise.all([
|
||||
execCommand(client, `df -hT -P -- ${path} | tail -n +2`),
|
||||
execCommand(client, `df -TkP -- ${path} | tail -n +2`),
|
||||
]);
|
||||
return (
|
||||
buildFilesystemList(
|
||||
parseDfLines(customBytes.stdout),
|
||||
parseDfLines(customHuman.stdout),
|
||||
1024,
|
||||
)[0] ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
detected = mergeMonitoredFilesystems(
|
||||
detected,
|
||||
monitored,
|
||||
customFilesystems,
|
||||
);
|
||||
}
|
||||
const filesystems = filterExcludedFilesystems(detected, excludedMounts);
|
||||
const primary = selectPrimaryFilesystem(filesystems);
|
||||
|
||||
return {
|
||||
percent: primary?.percent ?? null,
|
||||
usedHuman: primary?.usedHuman ?? null,
|
||||
totalHuman: primary?.totalHuman ?? null,
|
||||
availableHuman: primary?.availableHuman ?? null,
|
||||
mount: primary?.mount ?? null,
|
||||
filesystems,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
percent: null,
|
||||
usedHuman: null,
|
||||
totalHuman: null,
|
||||
availableHuman: null,
|
||||
mount: null,
|
||||
filesystems: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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";
|
||||
|
||||
function parseIptablesRule(line: string): FirewallRule | null {
|
||||
if (!line.startsWith("-A ")) return null;
|
||||
|
||||
const rule: FirewallRule = {
|
||||
chain: "",
|
||||
target: "",
|
||||
protocol: "all",
|
||||
source: "0.0.0.0/0",
|
||||
destination: "0.0.0.0/0",
|
||||
};
|
||||
|
||||
const chainMatch = line.match(/^-A\s+(\S+)/);
|
||||
if (chainMatch) {
|
||||
rule.chain = chainMatch[1];
|
||||
}
|
||||
|
||||
const targetMatch = line.match(/-j\s+(\S+)/);
|
||||
if (targetMatch) {
|
||||
rule.target = targetMatch[1];
|
||||
}
|
||||
|
||||
const protocolMatch = line.match(/-p\s+(\S+)/);
|
||||
if (protocolMatch) {
|
||||
rule.protocol = protocolMatch[1];
|
||||
}
|
||||
|
||||
const sourceMatch = line.match(/-s\s+(\S+)/);
|
||||
if (sourceMatch) {
|
||||
rule.source = sourceMatch[1];
|
||||
}
|
||||
|
||||
const destMatch = line.match(/-d\s+(\S+)/);
|
||||
if (destMatch) {
|
||||
rule.destination = destMatch[1];
|
||||
}
|
||||
|
||||
const dportMatch = line.match(/--dport\s+(\S+)/);
|
||||
if (dportMatch) {
|
||||
rule.dport = dportMatch[1];
|
||||
}
|
||||
|
||||
const sportMatch = line.match(/--sport\s+(\S+)/);
|
||||
if (sportMatch) {
|
||||
rule.sport = sportMatch[1];
|
||||
}
|
||||
|
||||
const stateMatch = line.match(/--state\s+(\S+)/);
|
||||
if (stateMatch) {
|
||||
rule.state = stateMatch[1];
|
||||
}
|
||||
|
||||
const interfaceMatch = line.match(/-i\s+(\S+)/);
|
||||
if (interfaceMatch) {
|
||||
rule.interface = interfaceMatch[1];
|
||||
}
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
||||
function parseIptablesOutput(output: string): FirewallChain[] {
|
||||
const chains: Map<string, FirewallChain> = new Map();
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
|
||||
if (policyMatch) {
|
||||
const [, chainName, policy] = policyMatch;
|
||||
chains.set(chainName, {
|
||||
name: chainName,
|
||||
policy: policy,
|
||||
rules: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rule = parseIptablesRule(trimmed);
|
||||
if (rule) {
|
||||
let chain = chains.get(rule.chain);
|
||||
if (!chain) {
|
||||
chain = {
|
||||
name: rule.chain,
|
||||
policy: "ACCEPT",
|
||||
rules: [],
|
||||
};
|
||||
chains.set(rule.chain, chain);
|
||||
}
|
||||
chain.rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(chains.values());
|
||||
}
|
||||
|
||||
function parseNftablesOutput(output: string): FirewallChain[] {
|
||||
const chains: FirewallChain[] = [];
|
||||
let currentChain: FirewallChain | null = null;
|
||||
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
const chainMatch = trimmed.match(
|
||||
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
|
||||
);
|
||||
if (chainMatch) {
|
||||
if (currentChain) {
|
||||
chains.push(currentChain);
|
||||
}
|
||||
currentChain = {
|
||||
name: chainMatch[1].toUpperCase(),
|
||||
policy: "ACCEPT",
|
||||
rules: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChain && trimmed.startsWith("policy ")) {
|
||||
const policyMatch = trimmed.match(/policy\s+(\S+)/);
|
||||
if (policyMatch) {
|
||||
currentChain.policy = policyMatch[1].toUpperCase();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChain && trimmed && !trimmed.startsWith("}")) {
|
||||
const rule: FirewallRule = {
|
||||
chain: currentChain.name,
|
||||
target: "",
|
||||
protocol: "all",
|
||||
source: "0.0.0.0/0",
|
||||
destination: "0.0.0.0/0",
|
||||
};
|
||||
|
||||
if (trimmed.includes("accept")) rule.target = "ACCEPT";
|
||||
else if (trimmed.includes("drop")) rule.target = "DROP";
|
||||
else if (trimmed.includes("reject")) rule.target = "REJECT";
|
||||
|
||||
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
|
||||
if (tcpMatch) {
|
||||
rule.protocol = "tcp";
|
||||
rule.dport = tcpMatch[1];
|
||||
}
|
||||
|
||||
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
|
||||
if (udpMatch) {
|
||||
rule.protocol = "udp";
|
||||
rule.dport = udpMatch[1];
|
||||
}
|
||||
|
||||
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
|
||||
if (saddrMatch) {
|
||||
rule.source = saddrMatch[1];
|
||||
}
|
||||
|
||||
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
|
||||
if (daddrMatch) {
|
||||
rule.destination = daddrMatch[1];
|
||||
}
|
||||
|
||||
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
|
||||
if (iifMatch) {
|
||||
rule.interface = iifMatch[1].replace(/"/g, "");
|
||||
}
|
||||
|
||||
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
|
||||
if (ctStateMatch) {
|
||||
rule.state = ctStateMatch[1].toUpperCase();
|
||||
}
|
||||
|
||||
if (rule.target) {
|
||||
currentChain.rules.push(rule);
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed === "}") {
|
||||
if (currentChain) {
|
||||
chains.push(currentChain);
|
||||
currentChain = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChain) {
|
||||
chains.push(currentChain);
|
||||
}
|
||||
|
||||
return chains;
|
||||
}
|
||||
|
||||
export async function collectFirewallMetrics(
|
||||
client: Client,
|
||||
): Promise<FirewallMetrics> {
|
||||
try {
|
||||
const iptablesResult = await execCommand(
|
||||
client,
|
||||
"iptables-save 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
|
||||
const chains = parseIptablesOutput(iptablesResult.stdout);
|
||||
const hasRules = chains.some((c) => c.rules.length > 0);
|
||||
|
||||
return {
|
||||
type: "iptables",
|
||||
status: hasRules ? "active" : "inactive",
|
||||
chains: chains.filter(
|
||||
(c) =>
|
||||
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const nftResult = await execCommand(
|
||||
client,
|
||||
"nft list ruleset 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (nftResult.stdout && nftResult.stdout.trim()) {
|
||||
const chains = parseNftablesOutput(nftResult.stdout);
|
||||
const hasRules = chains.some((c) => c.rules.length > 0);
|
||||
|
||||
return {
|
||||
type: "nftables",
|
||||
status: hasRules ? "active" : "inactive",
|
||||
chains,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "none",
|
||||
status: "unknown",
|
||||
chains: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
type: "none",
|
||||
status: "unknown",
|
||||
chains: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
toFixedNum,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface GpuDevice {
|
||||
index: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
driverVersion: string | null;
|
||||
utilizationPercent: number | null;
|
||||
memoryUsedMiB: number | null;
|
||||
memoryTotalMiB: number | null;
|
||||
memoryPercent: number | null;
|
||||
temperatureCelsius: number | null;
|
||||
powerDrawWatts: number | null;
|
||||
powerLimitWatts: number | null;
|
||||
fanPercent: number | null;
|
||||
}
|
||||
|
||||
export interface GpuProcess {
|
||||
/** Null when nvidia-smi reports a uuid that matches none of the listed GPUs. */
|
||||
gpuIndex: number | null;
|
||||
pid: number;
|
||||
name: string;
|
||||
memoryUsedMiB: number | null;
|
||||
}
|
||||
|
||||
export interface GpuMetrics {
|
||||
source: "nvidia-smi" | "none";
|
||||
gpus: GpuDevice[];
|
||||
processes: GpuProcess[];
|
||||
}
|
||||
|
||||
const SECTION_SEPARATOR = "---";
|
||||
|
||||
// The free-text name is queried last so commas inside it cannot shift the
|
||||
// numeric columns; the parsers rejoin everything past the fixed fields.
|
||||
const GPU_QUERY_FIELDS = [
|
||||
"index",
|
||||
"uuid",
|
||||
"driver_version",
|
||||
"utilization.gpu",
|
||||
"memory.used",
|
||||
"memory.total",
|
||||
"temperature.gpu",
|
||||
"power.draw",
|
||||
"power.limit",
|
||||
"fan.speed",
|
||||
"name",
|
||||
];
|
||||
const APPS_QUERY_FIELDS = ["gpu_uuid", "pid", "used_memory", "process_name"];
|
||||
|
||||
const NVIDIA_SMI_COMMAND = [
|
||||
"command -v nvidia-smi >/dev/null 2>&1 || exit 0;",
|
||||
`nvidia-smi --query-gpu=${GPU_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null;`,
|
||||
`echo ${SECTION_SEPARATOR};`,
|
||||
`nvidia-smi --query-compute-apps=${APPS_QUERY_FIELDS.join(",")} --format=csv,noheader,nounits 2>/dev/null`,
|
||||
].join(" ");
|
||||
|
||||
const NO_GPU: GpuMetrics = { source: "none", gpus: [], processes: [] };
|
||||
|
||||
/** Reads a numeric field; nvidia-smi writes "[N/A]", "[Not Supported]" etc. for missing ones. */
|
||||
function parseReading(raw: string | undefined, digits = 1): number | null {
|
||||
const value = raw?.trim();
|
||||
if (!value || value.startsWith("[")) return null;
|
||||
return toFixedNum(Number(value), digits);
|
||||
}
|
||||
|
||||
function parseInteger(raw: string | undefined): number | null {
|
||||
const value = raw?.trim();
|
||||
return value && /^\d+$/.test(value) ? Number(value) : null;
|
||||
}
|
||||
|
||||
function splitCsvRow(line: string, fixedFields: number): string[] | null {
|
||||
const parts = line.split(",").map((part) => part.trim());
|
||||
if (parts.length <= fixedFields) return null;
|
||||
return [...parts.slice(0, fixedFields), parts.slice(fixedFields).join(", ")];
|
||||
}
|
||||
|
||||
export function parseNvidiaSmiGpus(output: string): GpuDevice[] {
|
||||
const fixedFields = GPU_QUERY_FIELDS.length - 1;
|
||||
const gpus: GpuDevice[] = [];
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const row = splitCsvRow(line, fixedFields);
|
||||
if (!row) continue;
|
||||
|
||||
const [
|
||||
rawIndex,
|
||||
uuid,
|
||||
driverVersion,
|
||||
utilization,
|
||||
memoryUsed,
|
||||
memoryTotal,
|
||||
temperature,
|
||||
powerDraw,
|
||||
powerLimit,
|
||||
fan,
|
||||
name,
|
||||
] = row;
|
||||
const index = parseInteger(rawIndex);
|
||||
if (index === null || !uuid) continue;
|
||||
|
||||
const memoryUsedMiB = parseReading(memoryUsed, 0);
|
||||
const memoryTotalMiB = parseReading(memoryTotal, 0);
|
||||
|
||||
gpus.push({
|
||||
index,
|
||||
uuid,
|
||||
name,
|
||||
driverVersion:
|
||||
driverVersion && !driverVersion.startsWith("[") ? driverVersion : null,
|
||||
utilizationPercent: parseReading(utilization),
|
||||
memoryUsedMiB,
|
||||
memoryTotalMiB,
|
||||
memoryPercent:
|
||||
memoryUsedMiB !== null &&
|
||||
memoryTotalMiB !== null &&
|
||||
memoryTotalMiB !== 0
|
||||
? toFixedNum((memoryUsedMiB / memoryTotalMiB) * 100, 1)
|
||||
: null,
|
||||
temperatureCelsius: parseReading(temperature),
|
||||
powerDrawWatts: parseReading(powerDraw),
|
||||
powerLimitWatts: parseReading(powerLimit),
|
||||
fanPercent: parseReading(fan),
|
||||
});
|
||||
}
|
||||
|
||||
return gpus;
|
||||
}
|
||||
|
||||
export function parseNvidiaSmiProcesses(
|
||||
output: string,
|
||||
gpus: GpuDevice[],
|
||||
): GpuProcess[] {
|
||||
const indexByUuid = new Map(gpus.map((gpu) => [gpu.uuid, gpu.index]));
|
||||
const processes: GpuProcess[] = [];
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const row = splitCsvRow(line, APPS_QUERY_FIELDS.length - 1);
|
||||
if (!row) continue;
|
||||
|
||||
const [uuid, rawPid, memoryUsed, name] = row;
|
||||
const pid = parseInteger(rawPid);
|
||||
if (pid === null) continue;
|
||||
|
||||
processes.push({
|
||||
gpuIndex: indexByUuid.get(uuid) ?? null,
|
||||
pid,
|
||||
name,
|
||||
memoryUsedMiB: parseReading(memoryUsed, 0),
|
||||
});
|
||||
}
|
||||
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function parseNvidiaSmiOutput(stdout: string): GpuMetrics {
|
||||
const lines = stdout.split("\n");
|
||||
const separator = lines.findIndex(
|
||||
(line) => line.trim() === SECTION_SEPARATOR,
|
||||
);
|
||||
const gpuSection =
|
||||
separator === -1 ? stdout : lines.slice(0, separator).join("\n");
|
||||
const appsSection =
|
||||
separator === -1 ? "" : lines.slice(separator + 1).join("\n");
|
||||
|
||||
const gpus = parseNvidiaSmiGpus(gpuSection);
|
||||
if (gpus.length === 0) return NO_GPU;
|
||||
|
||||
return {
|
||||
source: "nvidia-smi",
|
||||
gpus,
|
||||
processes: parseNvidiaSmiProcesses(appsSection, gpus),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectGpuMetrics(client: Client): Promise<GpuMetrics> {
|
||||
try {
|
||||
// nvidia-smi can take a few seconds on hosts without persistence mode.
|
||||
const result = await execCommand(client, NVIDIA_SMI_COMMAND, 15000);
|
||||
return parseNvidiaSmiOutput(result.stdout);
|
||||
} catch {
|
||||
// expected when the host has no NVIDIA driver or the query times out
|
||||
return NO_GPU;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
export interface LoginStats {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
export async function collectLoginStats(client: Client): Promise<LoginStats> {
|
||||
const recentLogins: LoginRecord[] = [];
|
||||
const failedLogins: LoginRecord[] = [];
|
||||
const ipSet = new Set<string>();
|
||||
|
||||
try {
|
||||
const lastOut = await execCommand(
|
||||
client,
|
||||
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
|
||||
);
|
||||
|
||||
const lastLines = lastOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lastLines) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 10) {
|
||||
const user = parts[0];
|
||||
const tty = parts[1];
|
||||
const ip =
|
||||
parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
|
||||
|
||||
const timeStart = parts.indexOf(
|
||||
parts.find((p) => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "",
|
||||
);
|
||||
if (timeStart > 0 && parts.length > timeStart + 4) {
|
||||
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
|
||||
|
||||
if (user && user !== "wtmp" && tty !== "system") {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
parsedTime = isNaN(date.getTime())
|
||||
? timeStr || "unknown"
|
||||
: date.toISOString();
|
||||
} catch {
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
recentLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: parsedTime,
|
||||
status: "success",
|
||||
});
|
||||
if (ip !== "local") {
|
||||
ipSet.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
try {
|
||||
const failedOut = await execCommand(
|
||||
client,
|
||||
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
|
||||
);
|
||||
|
||||
const failedLines = failedOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of failedLines) {
|
||||
let user = "unknown";
|
||||
let ip = "unknown";
|
||||
let timeStr = "";
|
||||
|
||||
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
|
||||
if (userMatch) {
|
||||
user = userMatch[1];
|
||||
}
|
||||
|
||||
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
|
||||
if (ipMatch) {
|
||||
ip = ipMatch[1];
|
||||
}
|
||||
|
||||
const dateMatch = line.match(/^(\w+)\s+(\d+)\s+(\d+:\d+:\d+)/);
|
||||
if (dateMatch) {
|
||||
const [, month, day, time] = dateMatch;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const candidate = new Date(`${month} ${day}, ${currentYear} ${time}`);
|
||||
if (!isNaN(candidate.getTime()) && candidate > now) {
|
||||
// If parsed date is in the future, it's from last year
|
||||
timeStr = `${month} ${day}, ${currentYear - 1} ${time}`;
|
||||
} else {
|
||||
timeStr = `${month} ${day}, ${currentYear} ${time}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (user && ip) {
|
||||
let parsedTime: string;
|
||||
try {
|
||||
const date = timeStr ? new Date(timeStr) : null;
|
||||
parsedTime =
|
||||
date && !isNaN(date.getTime())
|
||||
? date.toISOString()
|
||||
: timeStr || "unknown";
|
||||
} catch {
|
||||
parsedTime = timeStr || "unknown";
|
||||
}
|
||||
|
||||
failedLogins.push({
|
||||
user,
|
||||
ip,
|
||||
time: parsedTime,
|
||||
status: "failed",
|
||||
});
|
||||
if (ip !== "unknown") {
|
||||
ipSet.add(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
recentLogins: recentLogins.slice(0, 10),
|
||||
failedLogins: failedLogins.slice(0, 10),
|
||||
totalLogins: recentLogins.length,
|
||||
uniqueIPs: ipSet.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
toFixedNum,
|
||||
kibToGiB,
|
||||
type HostPlatform,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
async function collectDarwinMemoryMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const [vmStatOut, memSizeOut] = await Promise.all([
|
||||
execCommand(client, "vm_stat"),
|
||||
execCommand(client, "sysctl -n hw.memsize"),
|
||||
]);
|
||||
|
||||
const totalBytes = Number((memSizeOut.stdout || "").trim());
|
||||
const pageSizeMatch = vmStatOut.stdout.match(/page size of (\d+) bytes/);
|
||||
const pageSize = pageSizeMatch ? Number(pageSizeMatch[1]) : 4096;
|
||||
|
||||
const getPages = (key: string): number | null => {
|
||||
const line = vmStatOut.stdout
|
||||
.split("\n")
|
||||
.find((l) => l.trim().startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/(\d+)\./);
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
|
||||
const active = getPages("Pages active");
|
||||
const wired = getPages("Pages wired down");
|
||||
const compressed = getPages("Pages occupied by compressor");
|
||||
|
||||
if (
|
||||
Number.isFinite(totalBytes) &&
|
||||
totalBytes > 0 &&
|
||||
active !== null &&
|
||||
wired !== null
|
||||
) {
|
||||
const usedPages = active + wired + (compressed ?? 0);
|
||||
const usedBytes = usedPages * pageSize;
|
||||
memPercent = Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100));
|
||||
usedGiB = kibToGiB(usedBytes / 1024);
|
||||
totalGiB = kibToGiB(totalBytes / 1024);
|
||||
}
|
||||
} catch {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Win32_OperatingSystem reports TotalVisibleMemorySize/FreePhysicalMemory in KB.
|
||||
async function collectWindowsMemoryMetrics(client: Client): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$os.TotalVisibleMemorySize; free=$os.FreePhysicalMemory} | ConvertTo-Json -Compress",
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const totalKb = Number(parsed?.total);
|
||||
const freeKb = Number(parsed?.free);
|
||||
if (Number.isFinite(totalKb) && Number.isFinite(freeKb) && totalKb > 0) {
|
||||
const usedKb = totalKb - freeKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectMemoryMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinMemoryMetrics(client);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsMemoryMetrics(client);
|
||||
}
|
||||
|
||||
let memPercent: number | null = null;
|
||||
let usedGiB: number | null = null;
|
||||
let totalGiB: number | null = null;
|
||||
|
||||
try {
|
||||
const memInfo = await execCommand(client, "cat /proc/meminfo");
|
||||
const lines = memInfo.stdout.split("\n");
|
||||
const getVal = (key: string) => {
|
||||
const line = lines.find((l) => l.startsWith(key));
|
||||
if (!line) return null;
|
||||
const m = line.match(/\d+/);
|
||||
return m ? Number(m[0]) : null;
|
||||
};
|
||||
const totalKb = getVal("MemTotal:");
|
||||
const availKb = getVal("MemAvailable:");
|
||||
if (totalKb && availKb && totalKb > 0) {
|
||||
const usedKb = totalKb - availKb;
|
||||
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
|
||||
usedGiB = kibToGiB(usedKb);
|
||||
totalGiB = kibToGiB(totalKb);
|
||||
}
|
||||
} catch {
|
||||
memPercent = null;
|
||||
usedGiB = null;
|
||||
totalGiB = null;
|
||||
}
|
||||
|
||||
return {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
|
||||
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface NetworkCounters {
|
||||
rx: string;
|
||||
tx: string;
|
||||
}
|
||||
|
||||
export function parseNetworkCounters(
|
||||
output: string,
|
||||
): Map<string, NetworkCounters> {
|
||||
const counters = new Map<string, NetworkCounters>();
|
||||
for (const line of output.split("\n").slice(2)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 10) {
|
||||
counters.set(parts[0].replace(":", ""), {
|
||||
rx: parts[1],
|
||||
tx: parts[9],
|
||||
});
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
|
||||
export function counterRate(
|
||||
before: string | undefined,
|
||||
after: string | undefined,
|
||||
elapsedSeconds: number,
|
||||
): number | null {
|
||||
const first = Number(before);
|
||||
const second = Number(after);
|
||||
if (
|
||||
!Number.isFinite(first) ||
|
||||
!Number.isFinite(second) ||
|
||||
second < first ||
|
||||
elapsedSeconds <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Math.round((second - first) / elapsedSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate is derived from the previous poll's counters rather than a same-poll
|
||||
* double-sample, so a poll no longer has to block for a fixed settle window.
|
||||
* Keyed by host, since counters from one host are meaningless against another.
|
||||
*/
|
||||
const previousSamples = new Map<
|
||||
number,
|
||||
{ counters: Map<string, NetworkCounters>; timestamp: number }
|
||||
>();
|
||||
|
||||
const previousWindowsSamples = new Map<
|
||||
number,
|
||||
{ rows: WindowsAdapterRow[]; timestamp: number }
|
||||
>();
|
||||
|
||||
export function clearNetworkSampleCache(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
previousSamples.clear();
|
||||
previousWindowsSamples.clear();
|
||||
return;
|
||||
}
|
||||
previousSamples.delete(hostId);
|
||||
previousWindowsSamples.delete(hostId);
|
||||
}
|
||||
|
||||
export function parseDarwinIfconfig(
|
||||
output: string,
|
||||
): Map<string, { ip: string; state: string }> {
|
||||
const map = new Map<string, { ip: string; state: string }>();
|
||||
let current: string | null = null;
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const ifaceMatch = rawLine.match(/^(\S+):\s*flags=\d+<([^>]*)>/);
|
||||
if (ifaceMatch) {
|
||||
current = ifaceMatch[1] === "lo0" ? null : ifaceMatch[1];
|
||||
if (current) {
|
||||
const state = ifaceMatch[2].includes("UP") ? "UP" : "DOWN";
|
||||
map.set(current, { ip: "", state });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const inetMatch = rawLine.match(/^\s+inet\s+(\d+\.\d+\.\d+\.\d+)/);
|
||||
if (inetMatch) {
|
||||
const existing = map.get(current);
|
||||
if (existing && !existing.ip) existing.ip = inetMatch[1];
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function parseDarwinNetstat(
|
||||
output: string,
|
||||
): Map<string, NetworkCounters> {
|
||||
const counters = new Map<string, NetworkCounters>();
|
||||
for (const line of output.split("\n").slice(1)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 10) continue;
|
||||
const name = parts[0];
|
||||
if (name === "lo0" || !parts[2]?.startsWith("<Link")) continue;
|
||||
if (!counters.has(name)) {
|
||||
counters.set(name, { rx: parts[6], tx: parts[9] });
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
|
||||
async function collectDarwinNetworkMetrics(
|
||||
client: Client,
|
||||
hostId?: number,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}>;
|
||||
}> {
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const ifconfigOut = await execCommand(client, "ifconfig -a 2>/dev/null");
|
||||
const ifMap = parseDarwinIfconfig(ifconfigOut.stdout);
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const netstatOut = await execCommand(client, "netstat -ib 2>/dev/null");
|
||||
const current = parseDarwinNetstat(netstatOut.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? previousSamples.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous
|
||||
? (readAt - previous.timestamp) / 1000
|
||||
: 0;
|
||||
if (ifMap.size === 0) {
|
||||
for (const name of current.keys()) {
|
||||
ifMap.set(name, { ip: "", state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
const c = current.get(name);
|
||||
const p = previous?.counters.get(name);
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: c?.rx ?? null,
|
||||
txBytes: c?.tx ?? null,
|
||||
rxRateBps: p ? counterRate(p.rx, c?.rx, elapsedSeconds) : null,
|
||||
txRateBps: p ? counterRate(p.tx, c?.tx, elapsedSeconds) : null,
|
||||
});
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
previousSamples.set(hostId, { counters: current, timestamp: readAt });
|
||||
}
|
||||
} catch {
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: null,
|
||||
txBytes: null,
|
||||
rxRateBps: null,
|
||||
txRateBps: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
|
||||
interface WindowsAdapterRow {
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rx: string;
|
||||
tx: string;
|
||||
}
|
||||
|
||||
const WINDOWS_ADAPTER_SCRIPT =
|
||||
"Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | ForEach-Object {" +
|
||||
" $stats = Get-NetAdapterStatistics -Name $_.Name -ErrorAction SilentlyContinue;" +
|
||||
" $addr = (Get-NetIPAddress -InterfaceIndex $_.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | Select-Object -First 1).IPAddress;" +
|
||||
" [PSCustomObject]@{name=$_.Name; ip=$addr; state='UP'; rx=$stats.ReceivedBytes; tx=$stats.SentBytes}" +
|
||||
" } | ConvertTo-Json -Compress";
|
||||
|
||||
export function parseWindowsAdapterJson(output: string): WindowsAdapterRow[] {
|
||||
const trimmed = output.trim();
|
||||
if (!trimmed) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const rows = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return rows
|
||||
.filter((row): row is Record<string, unknown> => Boolean(row?.name))
|
||||
.map((row) => ({
|
||||
name: String(row.name),
|
||||
ip: row.ip ? String(row.ip) : "",
|
||||
state: String(row.state ?? "UNKNOWN"),
|
||||
rx: String(row.rx ?? ""),
|
||||
tx: String(row.tx ?? ""),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function collectWindowsNetworkMetrics(
|
||||
client: Client,
|
||||
hostId?: number,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}>;
|
||||
}> {
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const result = await execPowerShell(client, WINDOWS_ADAPTER_SCRIPT);
|
||||
const currentRows = parseWindowsAdapterJson(result.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? previousWindowsSamples.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous ? (readAt - previous.timestamp) / 1000 : 0;
|
||||
const previousMap = new Map(
|
||||
(previous?.rows ?? []).map((row) => [row.name, row]),
|
||||
);
|
||||
|
||||
for (const row of currentRows) {
|
||||
const previousRow = previousMap.get(row.name);
|
||||
interfaces.push({
|
||||
name: row.name,
|
||||
ip: row.ip,
|
||||
state: row.state,
|
||||
rxBytes: row.rx || null,
|
||||
txBytes: row.tx || null,
|
||||
rxRateBps: previousRow
|
||||
? counterRate(previousRow.rx, row.rx, elapsedSeconds)
|
||||
: null,
|
||||
txRateBps: previousRow
|
||||
? counterRate(previousRow.tx, row.tx, elapsedSeconds)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (hostId !== undefined) {
|
||||
previousWindowsSamples.set(hostId, {
|
||||
rows: currentRows,
|
||||
timestamp: readAt,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
|
||||
export async function collectNetworkMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
hostId?: number,
|
||||
): Promise<{
|
||||
interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}>;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinNetworkMetrics(client, hostId);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsNetworkMetrics(client, hostId);
|
||||
}
|
||||
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
state: string;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
rxRateBps: number | null;
|
||||
txRateBps: number | null;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const ifconfigOut = await execCommand(
|
||||
client,
|
||||
"ip -o addr show 2>/dev/null | awk '{print $2,$4}' | grep -v '^lo' || true",
|
||||
);
|
||||
const netStatOut = await execCommand(
|
||||
client,
|
||||
"ip -o link show 2>/dev/null | awk '{gsub(/:/, \"\", $2); print $2,$9}' || true",
|
||||
);
|
||||
|
||||
const addrs = ifconfigOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const states = netStatOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const ifMap = new Map<string, { ip: string; state: string }>();
|
||||
for (const line of addrs) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const ip = parts[1].split("/")[0];
|
||||
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const line of states) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
if (name === "lo") continue;
|
||||
const state = parts[1];
|
||||
const existing = ifMap.get(name);
|
||||
if (existing) {
|
||||
existing.state = state;
|
||||
} else {
|
||||
ifMap.set(name, { ip: "", state });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const readAt = Date.now();
|
||||
const procNet = await execCommand(client, "cat /proc/net/dev");
|
||||
const rxTxMap = parseNetworkCounters(procNet.stdout);
|
||||
const previous =
|
||||
hostId !== undefined ? previousSamples.get(hostId) : undefined;
|
||||
const elapsedSeconds = previous
|
||||
? (readAt - previous.timestamp) / 1000
|
||||
: 0;
|
||||
if (ifMap.size === 0) {
|
||||
for (const name of rxTxMap.keys()) {
|
||||
if (name !== "lo") ifMap.set(name, { ip: "", state: "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
const rxTx = rxTxMap.get(name);
|
||||
const previousCounters = previous?.counters.get(name);
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: rxTx?.rx ?? null,
|
||||
txBytes: rxTx?.tx ?? null,
|
||||
rxRateBps: previousCounters
|
||||
? counterRate(previousCounters.rx, rxTx?.rx, elapsedSeconds)
|
||||
: null,
|
||||
txRateBps: previousCounters
|
||||
? counterRate(previousCounters.tx, rxTx?.tx, elapsedSeconds)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (hostId !== undefined) {
|
||||
previousSamples.set(hostId, {
|
||||
counters: rxTxMap,
|
||||
timestamp: readAt,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: null,
|
||||
txBytes: null,
|
||||
rxRateBps: null,
|
||||
txRateBps: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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";
|
||||
|
||||
function parseSsOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
const lines = output.split("\n").slice(1);
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const protocol = parts[0]?.toLowerCase();
|
||||
if (protocol !== "tcp" && protocol !== "udp") continue;
|
||||
|
||||
const state = parts[1];
|
||||
const localAddr = parts[4];
|
||||
|
||||
if (!localAddr) continue;
|
||||
|
||||
const lastColon = localAddr.lastIndexOf(":");
|
||||
if (lastColon === -1) continue;
|
||||
|
||||
const address = localAddr.substring(0, lastColon);
|
||||
const portStr = localAddr.substring(lastColon + 1);
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
if (isNaN(port)) continue;
|
||||
|
||||
const portEntry: ListeningPort = {
|
||||
protocol: protocol as "tcp" | "udp",
|
||||
localAddress: address.replace(/^\[|\]$/g, ""),
|
||||
localPort: port,
|
||||
state: protocol === "tcp" ? state : undefined,
|
||||
};
|
||||
|
||||
const processInfo = parts[6];
|
||||
if (processInfo && processInfo.startsWith("users:")) {
|
||||
const pidMatch = processInfo.match(/pid=(\d+)/);
|
||||
const nameMatch = processInfo.match(/\("([^"]+)"/);
|
||||
if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10);
|
||||
if (nameMatch) portEntry.process = nameMatch[1];
|
||||
}
|
||||
|
||||
ports.push(portEntry);
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
function parseNetstatOutput(output: string): ListeningPort[] {
|
||||
const ports: ListeningPort[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const proto = parts[0]?.toLowerCase();
|
||||
if (!proto) continue;
|
||||
|
||||
let protocol: "tcp" | "udp";
|
||||
if (proto.startsWith("tcp")) {
|
||||
protocol = "tcp";
|
||||
} else if (proto.startsWith("udp")) {
|
||||
protocol = "udp";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const localAddr = parts[3];
|
||||
if (!localAddr) continue;
|
||||
|
||||
const lastColon = localAddr.lastIndexOf(":");
|
||||
if (lastColon === -1) continue;
|
||||
|
||||
const address = localAddr.substring(0, lastColon);
|
||||
const portStr = localAddr.substring(lastColon + 1);
|
||||
const port = parseInt(portStr, 10);
|
||||
|
||||
if (isNaN(port)) continue;
|
||||
|
||||
const portEntry: ListeningPort = {
|
||||
protocol,
|
||||
localAddress: address,
|
||||
localPort: port,
|
||||
};
|
||||
|
||||
if (protocol === "tcp" && parts.length >= 6) {
|
||||
portEntry.state = parts[5];
|
||||
}
|
||||
|
||||
const pidProgram = parts[parts.length - 1];
|
||||
if (pidProgram && pidProgram.includes("/")) {
|
||||
const [pidStr, process] = pidProgram.split("/");
|
||||
const pid = parseInt(pidStr, 10);
|
||||
if (!isNaN(pid)) portEntry.pid = pid;
|
||||
if (process) portEntry.process = process;
|
||||
}
|
||||
|
||||
ports.push(portEntry);
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
export async function collectPortsMetrics(
|
||||
client: Client,
|
||||
): Promise<PortsMetrics> {
|
||||
try {
|
||||
const ssResult = await execCommand(client, "ss -tulnp 2>/dev/null", 15000);
|
||||
|
||||
if (ssResult.stdout && ssResult.stdout.includes("Local")) {
|
||||
const ports = parseSsOutput(ssResult.stdout);
|
||||
return {
|
||||
source: "ss",
|
||||
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||
};
|
||||
}
|
||||
|
||||
const netstatResult = await execCommand(
|
||||
client,
|
||||
"netstat -tulnp 2>/dev/null",
|
||||
15000,
|
||||
);
|
||||
|
||||
if (netstatResult.stdout && netstatResult.stdout.includes("Local")) {
|
||||
const ports = parseNetstatOutput(netstatResult.stdout);
|
||||
return {
|
||||
source: "netstat",
|
||||
ports: ports.sort((a, b) => a.localPort - b.localPort),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
source: "none",
|
||||
ports: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
running: number | null;
|
||||
top: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}>;
|
||||
}> {
|
||||
let totalProcesses: number | null = null;
|
||||
let runningProcesses: number | null = null;
|
||||
const topProcesses: Array<{
|
||||
pid: string;
|
||||
user: string;
|
||||
cpu: string;
|
||||
mem: string;
|
||||
command: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const psOut = await execCommand(
|
||||
client,
|
||||
"(ps aux --sort=-%cpu 2>/dev/null || ps aux) | head -n 11",
|
||||
);
|
||||
const psLines = psOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (psLines.length > 1) {
|
||||
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
|
||||
const parts = psLines[i].split(/\s+/);
|
||||
if (parts.length >= 11) {
|
||||
const cpuVal = Number(parts[2]);
|
||||
const memVal = Number(parts[3]);
|
||||
topProcesses.push({
|
||||
pid: parts[1],
|
||||
user: parts[0],
|
||||
cpu: Number.isFinite(cpuVal) ? cpuVal.toString() : "0",
|
||||
mem: Number.isFinite(memVal) ? memVal.toString() : "0",
|
||||
command: parts.slice(10).join(" ").substring(0, 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const procCount = await execCommand(client, "ps aux | wc -l");
|
||||
const runningCount = await execCommand(client, "ps aux | grep -c ' R '");
|
||||
|
||||
const totalCount = Number(procCount.stdout.trim()) - 1;
|
||||
totalProcesses = Number.isFinite(totalCount) ? totalCount : null;
|
||||
|
||||
const runningCount2 = Number(runningCount.stdout.trim());
|
||||
runningProcesses = Number.isFinite(runningCount2) ? runningCount2 : null;
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
total: totalProcesses,
|
||||
running: runningProcesses,
|
||||
top: topProcesses,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
async function collectWindowsSystemMetrics(client: Client): Promise<{
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
os: string | null;
|
||||
}> {
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{hostname=$env:COMPUTERNAME; kernel=[System.Environment]::OSVersion.Version.ToString(); os=$os.Caption} | ConvertTo-Json -Compress",
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
hostname = parsed?.hostname ? String(parsed.hostname) : null;
|
||||
kernel = parsed?.kernel ? String(parsed.kernel) : null;
|
||||
os = parsed?.os ? String(parsed.os).trim() : null;
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return { hostname, kernel, os };
|
||||
}
|
||||
|
||||
export async function collectSystemMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
os: string | null;
|
||||
}> {
|
||||
if (platform === "windows") {
|
||||
return collectWindowsSystemMetrics(client);
|
||||
}
|
||||
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
|
||||
try {
|
||||
const hostnameOut = await execCommand(client, "hostname");
|
||||
const kernelOut = await execCommand(client, "uname -r");
|
||||
const osOut = await execCommand(
|
||||
client,
|
||||
"command -v sw_vers >/dev/null 2>&1 && echo \"$(sw_vers -productName) $(sw_vers -productVersion)\" || (cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'\"' -f2)",
|
||||
);
|
||||
|
||||
hostname = hostnameOut.stdout.trim() || null;
|
||||
kernel = kernelOut.stdout.trim() || null;
|
||||
os = osOut.stdout.trim() || null;
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
hostname,
|
||||
kernel,
|
||||
os,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
toFixedNum,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
export interface TemperatureSensor {
|
||||
label: string;
|
||||
celsius: number;
|
||||
}
|
||||
|
||||
export interface TemperatureMetrics {
|
||||
source: "sysfs" | "sensors" | "none";
|
||||
highestCelsius: number | null;
|
||||
sensors: TemperatureSensor[];
|
||||
}
|
||||
|
||||
function normalizeSensor(
|
||||
label: string,
|
||||
celsius: number,
|
||||
): TemperatureSensor | null {
|
||||
const cleanLabel = label.trim().replace(/\s+/g, " ");
|
||||
if (!cleanLabel || !Number.isFinite(celsius)) return null;
|
||||
return {
|
||||
label: cleanLabel,
|
||||
celsius: toFixedNum(celsius, 1) ?? celsius,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeSensors(
|
||||
source: TemperatureMetrics["source"],
|
||||
sensors: TemperatureSensor[],
|
||||
): TemperatureMetrics {
|
||||
const highest = sensors.reduce<number | null>(
|
||||
(max, sensor) =>
|
||||
max === null ? sensor.celsius : Math.max(max, sensor.celsius),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
source: sensors.length > 0 ? source : "none",
|
||||
highestCelsius:
|
||||
highest === null ? null : (toFixedNum(highest, 1) ?? highest),
|
||||
sensors,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSysfsThermalOutput(output: string): TemperatureSensor[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [label, rawValue] = line.split("\t");
|
||||
const value = Number(rawValue);
|
||||
if (!label || !Number.isFinite(value)) return null;
|
||||
const celsius = Math.abs(value) >= 1000 ? value / 1000 : value;
|
||||
return normalizeSensor(label, celsius);
|
||||
})
|
||||
.filter((sensor): sensor is TemperatureSensor => sensor !== null);
|
||||
}
|
||||
|
||||
export function parseSensorsOutput(output: string): TemperatureSensor[] {
|
||||
const seen = new Set<string>();
|
||||
const sensors: TemperatureSensor[] = [];
|
||||
let device = "";
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !line.startsWith(" ") && !trimmed.includes(":")) {
|
||||
device = trimmed;
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^\s*([^:]+):\s*([+-]?\d+(?:\.\d+)?)\s*°?C\b/i);
|
||||
if (!match) continue;
|
||||
|
||||
const label = device ? `${device}: ${match[1]}` : match[1];
|
||||
const sensor = normalizeSensor(label, Number(match[2]));
|
||||
if (!sensor) continue;
|
||||
|
||||
const key = `${sensor.label.toLowerCase()}:${sensor.celsius}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
sensors.push(sensor);
|
||||
}
|
||||
|
||||
return sensors;
|
||||
}
|
||||
|
||||
export async function collectTemperatureMetrics(
|
||||
client: Client,
|
||||
): Promise<TemperatureMetrics> {
|
||||
try {
|
||||
const sysfs = await execCommand(
|
||||
client,
|
||||
[
|
||||
"for zone in /sys/class/thermal/thermal_zone*; do",
|
||||
'[ -r "$zone/temp" ] || continue;',
|
||||
'label="$(cat "$zone/type" 2>/dev/null || basename "$zone")";',
|
||||
'value="$(cat "$zone/temp" 2>/dev/null || true)";',
|
||||
'[ -n "$value" ] && printf "%s\\t%s\\n" "$label" "$value";',
|
||||
"done",
|
||||
].join(" "),
|
||||
10000,
|
||||
);
|
||||
const sensors = parseSysfsThermalOutput(sysfs.stdout);
|
||||
if (sensors.length > 0) {
|
||||
return summarizeSensors("sysfs", sensors);
|
||||
}
|
||||
} catch {
|
||||
// expected on systems without readable thermal zones
|
||||
}
|
||||
|
||||
try {
|
||||
const lmSensors = await execCommand(client, "sensors 2>/dev/null", 10000);
|
||||
const sensors = parseSensorsOutput(lmSensors.stdout);
|
||||
if (sensors.length > 0) {
|
||||
return summarizeSensors("sensors", sensors);
|
||||
}
|
||||
} catch {
|
||||
// expected when lm-sensors is not installed
|
||||
}
|
||||
|
||||
return {
|
||||
source: "none",
|
||||
highestCelsius: null,
|
||||
sensors: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Client } from "ssh2";
|
||||
import {
|
||||
execCommand,
|
||||
execPowerShell,
|
||||
type HostPlatform,
|
||||
} from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
||||
|
||||
function formatUptime(uptimeSeconds: number): string {
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
async function collectDarwinUptimeMetrics(client: Client): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const bootTimeOut = await execCommand(client, "sysctl -n kern.boottime");
|
||||
// "{ sec = 1700000000, usec = 123456 } Thu Nov 16 ..."
|
||||
const match = bootTimeOut.stdout.match(/sec\s*=\s*(\d+)/);
|
||||
if (match) {
|
||||
const bootSeconds = Number(match[1]);
|
||||
if (Number.isFinite(bootSeconds)) {
|
||||
uptimeSeconds = Math.max(0, Date.now() / 1000 - bootSeconds);
|
||||
uptimeFormatted = formatUptime(uptimeSeconds);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectWindowsUptimeMetrics(client: Client): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(
|
||||
client,
|
||||
"$os=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{seconds=((Get-Date) - $os.LastBootUpTime).TotalSeconds} | ConvertTo-Json -Compress",
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
const seconds = Number(parsed?.seconds);
|
||||
if (Number.isFinite(seconds)) {
|
||||
uptimeSeconds = Math.max(0, seconds);
|
||||
uptimeFormatted = formatUptime(uptimeSeconds);
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectUptimeMetrics(
|
||||
client: Client,
|
||||
platform?: HostPlatform,
|
||||
): Promise<{
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
}> {
|
||||
if (platform === "darwin") {
|
||||
return collectDarwinUptimeMetrics(client);
|
||||
}
|
||||
if (platform === "windows") {
|
||||
return collectWindowsUptimeMetrics(client);
|
||||
}
|
||||
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
|
||||
try {
|
||||
const uptimeOut = await execCommand(client, "cat /proc/uptime");
|
||||
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
|
||||
if (uptimeParts.length >= 1) {
|
||||
uptimeSeconds = Number(uptimeParts[0]);
|
||||
if (Number.isFinite(uptimeSeconds)) {
|
||||
uptimeFormatted = formatUptime(uptimeSeconds);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
|
||||
return {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeFormatted,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { HostMetricsTab } from "./HostMetricsTab.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
|
||||
interface HostMetricsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const HostMetricsApp: React.FC<HostMetricsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HostMetricsTab
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostMetricsApp;
|
||||
@@ -0,0 +1,843 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.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 "@/components/SnippetVariablesDialog";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { hasSnippetInputs } from "@/lib/snippet-variables.ts";
|
||||
import type { Snippet } from "@/types/ui-types.ts";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
|
||||
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,
|
||||
IMPLEMENTED_CARD_IDS,
|
||||
getCardDefinition,
|
||||
defaultColSpanFor,
|
||||
defaultHeightFor,
|
||||
type MetricCardHistories,
|
||||
} from "./cards";
|
||||
import { appendGpuHistories } from "./cards/gpu-history";
|
||||
import { metricsChangeKey } from "./metrics-change-key";
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
type ConnectionLogError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
};
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
authType?: string;
|
||||
port?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface HostMetricsProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
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,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: HostMetricsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [histories, setHistories] = React.useState<MetricCardHistories>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
gpu: {},
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [totpVerified, setTotpVerified] = React.useState(false);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editMode, setEditMode] = React.useState(false);
|
||||
const [runningAction, setRunningAction] = React.useState<{
|
||||
action: QuickAction;
|
||||
snippet: Snippet;
|
||||
} | null>(null);
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
const snippetsCacheRef = React.useRef<Snippet[] | null>(null);
|
||||
|
||||
const statsConfig = React.useMemo(
|
||||
() => parseStatsConfig(currentHostConfig?.statsConfig),
|
||||
[currentHostConfig?.statsConfig],
|
||||
);
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
||||
|
||||
const hostId = currentHostConfig?.id ?? null;
|
||||
const { layout, setLayout } = useHostMetricsPreferences(hostId);
|
||||
|
||||
const metricsPrefs = useAreaPreferences("hostMetrics");
|
||||
|
||||
const effectiveLayout: HostMetricsLayout = React.useMemo(() => {
|
||||
// A saved layout is user-authored, so the preset never rewrites it -- it
|
||||
// only decides the shape of the first layout a host gets.
|
||||
if (layout) return layout;
|
||||
return defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
metricsPrefs.columns,
|
||||
);
|
||||
}, [layout, statsConfig.enabledWidgets, metricsPrefs.columns]);
|
||||
|
||||
// Only render/keep cards that are implemented (metric cards in Phase A).
|
||||
const visibleSlots = React.useMemo(
|
||||
() => effectiveLayout.slots.filter((s) => getCardDefinition(s.id)),
|
||||
[effectiveLayout.slots],
|
||||
);
|
||||
|
||||
const cardCatalog: GridCardCatalogEntry[] = React.useMemo(
|
||||
() =>
|
||||
IMPLEMENTED_CARD_IDS.map((id) => ({
|
||||
id,
|
||||
label: t(CARD_DEFINITIONS[id].labelKey),
|
||||
defaultColSpan: defaultColSpanFor(id),
|
||||
defaultHeight: defaultHeightFor(id),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const cardLabel = React.useCallback(
|
||||
(id: string) => {
|
||||
const def = getCardDefinition(id);
|
||||
return def ? t(def.labelKey) : id;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const renderCard = React.useCallback(
|
||||
(id: string) => {
|
||||
const def = getCardDefinition(id);
|
||||
if (!def) return null;
|
||||
return def.render({ metrics, histories, hostId });
|
||||
},
|
||||
[metrics, histories, hostId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onVis = () => setIsPageVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, []);
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
sendMetricsHeartbeat(viewerSessionId).catch(() => {});
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewerSessionId, isActuallyVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setHistories({ cpu: [], memory: [], disk: [], gpu: {} });
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const logServerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("server_stats", currentHostConfig.id, hostName);
|
||||
} catch {
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const pushHistory = React.useCallback((data: ServerMetrics) => {
|
||||
setHistories((prev) => {
|
||||
const add = (arr: number[], v: number | null | undefined) =>
|
||||
[...arr, v ?? 0].slice(-HISTORY_LEN);
|
||||
return {
|
||||
cpu: add(prev.cpu, data.cpu?.percent),
|
||||
memory: add(prev.memory, data.memory?.percent),
|
||||
disk: add(prev.disk, data.disk?.percent),
|
||||
gpu: appendGpuHistories(prev.gpu, data.gpu?.gpus, HISTORY_LEN),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTOTPSubmit = async (totpCode: string) => {
|
||||
if (!totpSessionId || !currentHostConfig) return;
|
||||
try {
|
||||
const result = await submitMetricsTOTP(totpSessionId, totpCode);
|
||||
if (result.success) {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setTotpVerified(true);
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
} else {
|
||||
toast.error(t("hostMetrics.totpFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hostMetrics.totpFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPCancel = async () => {
|
||||
setTotpRequired(false);
|
||||
if (currentHostConfig?.id) {
|
||||
await stopMetricsPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!statusCheckEnabled || !currentHostConfig?.id || !isActuallyVisible) {
|
||||
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,
|
||||
]);
|
||||
|
||||
const stopMetricsPollingRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchMetrics = React.useCallback(async (): Promise<void> => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (currentHostConfig.authType === "none") {
|
||||
toast.error(t("hostMetrics.noneAuthNotSupported"));
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
throw new Error(t("hostMetrics.noneAuthNotSupported"));
|
||||
}
|
||||
|
||||
if (!totpVerified) {
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
|
||||
});
|
||||
const result = await startMetricsPolling(currentHostConfig.id);
|
||||
result?.connectionLogs?.forEach((log) =>
|
||||
addLog(log as ConnectionLogPayload),
|
||||
);
|
||||
if (result.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(result.sessionId || null);
|
||||
setTotpPrompt(result.prompt || "Verification code");
|
||||
// The TOTP dialog gates further progress; retry loop pauses via `enabled`.
|
||||
return;
|
||||
}
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
}
|
||||
|
||||
// The connect above only opens the SSH session; the first real sample is
|
||||
// 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);
|
||||
for (let i = 0; !data && i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
data = await getServerMetricsById(currentHostConfig.id);
|
||||
}
|
||||
if (!data) {
|
||||
throw new Error(t("hostMetrics.connectionFailed"));
|
||||
}
|
||||
|
||||
setMetrics(data);
|
||||
pushHistory(data);
|
||||
setServerStatus("online");
|
||||
logServerActivity();
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connected",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
|
||||
let signature = metricsChangeKey(data);
|
||||
const minIntervalMs = statsConfig.metricsInterval * 1000;
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = runAdaptivePolling(
|
||||
async () => {
|
||||
const next = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!next) throw new Error(t("hostMetrics.connectionFailed"));
|
||||
const nextSignature = metricsChangeKey(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
setMetrics(next);
|
||||
pushHistory(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs,
|
||||
maxIntervalMs: Math.min(120_000, minIntervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ runImmediately: false },
|
||||
);
|
||||
}, [
|
||||
currentHostConfig,
|
||||
totpVerified,
|
||||
statsConfig.metricsInterval,
|
||||
addLog,
|
||||
t,
|
||||
currentTab,
|
||||
removeTab,
|
||||
pushHistory,
|
||||
]);
|
||||
|
||||
const metricsRetry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchMetrics();
|
||||
if (!totpRequired) metricsRetry.markConnected();
|
||||
} catch (error: unknown) {
|
||||
const logError = error as ConnectionLogError;
|
||||
if (logError.connectionLogs) {
|
||||
logError.connectionLogs.forEach((log) => addLog(log));
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("hostMetrics.connectionFailed"),
|
||||
});
|
||||
}
|
||||
metricsRetry.markFailed();
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
isPageVisible &&
|
||||
metricsEnabled &&
|
||||
!totpRequired &&
|
||||
!!currentHostConfig?.id,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const metricsRetryRef = React.useRef(metricsRetry);
|
||||
metricsRetryRef.current = metricsRetry;
|
||||
|
||||
// Connects once per host and stays connected while this tab exists, even
|
||||
// when the user switches to another tab and back. Only the browser tab
|
||||
// going into the background (isPageVisible) pauses/resumes it -- switching
|
||||
// between Termix tabs must not tear down and reconnect the session.
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const stopMetrics = async () => {
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
await stopMetricsPolling(
|
||||
currentHostConfig.id,
|
||||
viewerSessionId || undefined,
|
||||
).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (isPageVisible) {
|
||||
clearLogs();
|
||||
metricsRetryRef.current.reset();
|
||||
metricsRetryRef.current.retryNow();
|
||||
} else {
|
||||
stopMetrics();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
stopMetricsPollingRef.current?.();
|
||||
stopMetricsPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
stopMetricsPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, isPageVisible, metricsEnabled]);
|
||||
|
||||
// After a successful TOTP submit, resume the connect flow immediately.
|
||||
React.useEffect(() => {
|
||||
if (totpVerified && !totpRequired) {
|
||||
metricsRetryRef.current.reset();
|
||||
metricsRetryRef.current.retryNow();
|
||||
}
|
||||
}, [totpVerified, totpRequired]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
|
||||
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (metricsRetry.status !== "connected") {
|
||||
metricsRetry.retryNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (data) {
|
||||
setMetrics(data);
|
||||
pushHistory(data);
|
||||
}
|
||||
} catch {
|
||||
setServerStatus("offline");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function executeQuickAction(
|
||||
action: QuickAction,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
if (!currentHostConfig) return;
|
||||
setExecutingActions((prev) => new Set(prev).add(action.snippetId));
|
||||
toast.loading(
|
||||
t("hostMetrics.executingQuickAction", { name: action.name }),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
Object.keys(inputValues).length > 0 ? inputValues : undefined,
|
||||
);
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("hostMetrics.quickActionSuccess", { name: action.name }),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output?.substring(0, 200),
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("hostMetrics.quickActionFailed", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.error || result.output,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("hostMetrics.quickActionError", { name: action.name }), {
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: getErrorMessage(error),
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function runQuickActionWithConfirm(
|
||||
action: QuickAction,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
const run = () => void executeQuickAction(action, inputValues);
|
||||
if (!shouldConfirm) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", { name: action.name }),
|
||||
run,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runQuickAction(action: QuickAction) {
|
||||
if (!currentHostConfig) return;
|
||||
try {
|
||||
if (!snippetsCacheRef.current) {
|
||||
snippetsCacheRef.current =
|
||||
(await getSnippets()) as unknown as Snippet[];
|
||||
}
|
||||
const snippet = snippetsCacheRef.current?.find(
|
||||
(s) => s.id === action.snippetId,
|
||||
);
|
||||
if (snippet && hasSnippetInputs(snippet.content)) {
|
||||
setRunningAction({ action, snippet });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through and run with no inputs if the snippet lookup fails
|
||||
}
|
||||
runQuickActionWithConfirm(action, {});
|
||||
}
|
||||
|
||||
const showCards =
|
||||
metricsEnabled && metricsRetry.status === "connected" && metrics;
|
||||
const showOffline =
|
||||
metricsEnabled &&
|
||||
metricsRetry.status !== "connecting" &&
|
||||
!metrics &&
|
||||
serverStatus === "offline";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-x-hidden overflow-y-auto">
|
||||
{!totpRequired &&
|
||||
(metricsRetry.status === "connected" || showOffline) && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<>
|
||||
<div className="mr-3 flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map(
|
||||
(action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={() => void runQuickAction(action)}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<RefreshCw className="mr-1 size-3 animate-spin" />
|
||||
{action.name}
|
||||
</>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-3 h-8"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{editMode && (
|
||||
<>
|
||||
<ColumnCountStepper
|
||||
columns={effectiveLayout.columns}
|
||||
onChange={(columns) =>
|
||||
setLayout({ ...effectiveLayout, columns })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 text-xs text-muted-foreground"
|
||||
onClick={() =>
|
||||
setLayout(
|
||||
defaultLayoutFromWidgets(
|
||||
statsConfig.enabledWidgets ?? [],
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("hostMetrics.reset")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={editMode ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="ml-1"
|
||||
title={t("hostMetrics.customize")}
|
||||
onClick={() => setEditMode((v) => !v)}
|
||||
>
|
||||
<LayoutDashboard className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="ml-1 gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("hostMetrics.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center gap-2 border border-dashed border-accent-brand/40 bg-accent-brand/5 px-4 py-2">
|
||||
<LayoutDashboard className="size-3.5 shrink-0 text-accent-brand" />
|
||||
<span className="text-xs font-semibold text-accent-brand">
|
||||
{t("hostMetrics.editModeInstructions")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCards && (
|
||||
<div className="px-3 pt-3 pb-3">
|
||||
<CardGridCanvas
|
||||
slots={visibleSlots}
|
||||
columns={effectiveLayout.columns}
|
||||
editMode={editMode}
|
||||
renderCard={renderCard}
|
||||
cardCatalog={cardCatalog}
|
||||
cardLabel={cardLabel}
|
||||
onChange={(slots, columns) =>
|
||||
setLayout({
|
||||
slots: slots as HostMetricsLayout["slots"],
|
||||
columns,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metricsEnabled && !totpRequired && (
|
||||
<ConnectionScreen
|
||||
status={showOffline ? "connected" : metricsRetry.status}
|
||||
message={t("hostMetrics.connecting")}
|
||||
attempt={metricsRetry.attempt}
|
||||
maxAttempts={metricsRetry.maxAttempts}
|
||||
nextRetryInMs={metricsRetry.nextRetryInMs}
|
||||
onManualRetry={metricsRetry.retryNow}
|
||||
logPosition={
|
||||
metricsRetry.status === "error" ||
|
||||
metricsRetry.status === "disconnected"
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
emptyState={
|
||||
showOffline ? (
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("hostMetrics.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{t("hostMetrics.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTOTPSubmit}
|
||||
onCancel={handleTOTPCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
{runningAction && (
|
||||
<SnippetVariablesDialog
|
||||
snippet={runningAction.snippet}
|
||||
host={
|
||||
currentHostConfig
|
||||
? {
|
||||
ip: currentHostConfig.ip,
|
||||
username: currentHostConfig.username,
|
||||
port: currentHostConfig.port,
|
||||
name: currentHostConfig.name,
|
||||
}
|
||||
: null
|
||||
}
|
||||
onCancel={() => setRunningAction(null)}
|
||||
onConfirm={(_resolvedContent, inputValues) => {
|
||||
const action = runningAction.action;
|
||||
setRunningAction(null);
|
||||
runQuickActionWithConfirm(action, inputValues);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostMetricsTab(props: HostMetricsProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<HostMetricsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type HistoryTab = "live" | "1h" | "6h" | "24h" | "7d";
|
||||
|
||||
const TABS: HistoryTab[] = ["live", "1h", "6h", "24h", "7d"];
|
||||
|
||||
export function CardTimeTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: HistoryTab;
|
||||
onChange: (tab: HistoryTab) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onChange(tab)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest transition-colors border",
|
||||
value === tab
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab === "live" ? t("hostMetrics.tabLive") : tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
function computeChartData(rows: MetricsHistoryRow[]) {
|
||||
return {
|
||||
data: rows.map((r) => r.cpu_percent),
|
||||
timestamps: rows.map((r) => r.ts),
|
||||
};
|
||||
}
|
||||
|
||||
export function CpuCard({
|
||||
metrics,
|
||||
history,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const percent = metrics?.cpu?.percent ?? null;
|
||||
const cores = metrics?.cpu?.cores ?? null;
|
||||
const load = metrics?.cpu?.load ?? null;
|
||||
|
||||
const series: LineChartSeries[] = [
|
||||
{
|
||||
key: "cpu",
|
||||
label: t("hostMetrics.cpuUsage"),
|
||||
color: "var(--accent-brand)",
|
||||
data: rows?.map((r) => r.cpu_percent) ?? [],
|
||||
},
|
||||
];
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.cpuUsage")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
action={
|
||||
hostId != null ? (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "live" && (
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge
|
||||
value={percent}
|
||||
caption={
|
||||
cores !== null
|
||||
? t("hostMetrics.cpuCores", { count: cores })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{load && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<MiniStat caption="1m" value={load[0].toFixed(2)} />
|
||||
<MiniStat caption="5m" value={load[1].toFixed(2)} />
|
||||
<MiniStat caption="15m" value={load[2].toFixed(2)} />
|
||||
</div>
|
||||
)}
|
||||
<Sparkline
|
||||
data={[...history, percent ?? 0]}
|
||||
domain={[0, 100]}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length === 0 && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length > 0 && (
|
||||
<LineChart
|
||||
series={series}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { FilesystemPicker } from "./FilesystemPicker";
|
||||
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
export function DiskCard({
|
||||
metrics,
|
||||
history,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const filesystems = useMemo(
|
||||
() => metrics?.disk?.filesystems ?? [],
|
||||
[metrics?.disk?.filesystems],
|
||||
);
|
||||
const defaultMount = metrics?.disk?.mount ?? null;
|
||||
const [selectedMount, setSelectedMount] = useState<string | null>(null);
|
||||
|
||||
// Drop a stale selection if the mount disappears between polls, so the card
|
||||
// falls back to the primary filesystem instead of rendering nothing.
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedMount !== null &&
|
||||
filesystems.length > 0 &&
|
||||
!filesystems.some((fs) => fs.mount === selectedMount)
|
||||
) {
|
||||
setSelectedMount(null);
|
||||
}
|
||||
}, [filesystems, selectedMount]);
|
||||
|
||||
const activeMount = selectedMount ?? defaultMount;
|
||||
const activeFs = filesystems.find((fs) => fs.mount === activeMount) ?? null;
|
||||
const isCustomMount = selectedMount !== null && activeFs !== null;
|
||||
|
||||
const percent = isCustomMount
|
||||
? activeFs.percent
|
||||
: (metrics?.disk?.percent ?? null);
|
||||
const usedHuman = isCustomMount
|
||||
? activeFs.usedHuman
|
||||
: (metrics?.disk?.usedHuman ?? null);
|
||||
const totalHuman = isCustomMount
|
||||
? activeFs.totalHuman
|
||||
: (metrics?.disk?.totalHuman ?? null);
|
||||
const availableHuman = isCustomMount
|
||||
? activeFs.availableHuman
|
||||
: (metrics?.disk?.availableHuman ?? null);
|
||||
|
||||
const series: LineChartSeries[] = [
|
||||
{
|
||||
key: "disk",
|
||||
label: t("hostMetrics.diskUsage"),
|
||||
color: "var(--chart-4)",
|
||||
data: rows?.map((r) => r.disk_percent) ?? [],
|
||||
},
|
||||
];
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.diskUsage")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
{activeTab === "live" && (
|
||||
<FilesystemPicker
|
||||
filesystems={filesystems}
|
||||
value={activeMount}
|
||||
onChange={setSelectedMount}
|
||||
/>
|
||||
)}
|
||||
{hostId != null && (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "live" && (
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge value={percent} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MiniStat
|
||||
caption={activeMount ?? t("hostMetrics.disk")}
|
||||
value={
|
||||
usedHuman && totalHuman
|
||||
? `${usedHuman}/${totalHuman}`
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.available")}
|
||||
value={availableHuman ?? "N/A"}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline
|
||||
data={[...history, percent ?? 0]}
|
||||
domain={[0, 100]}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length === 0 && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length > 0 && (
|
||||
<LineChart
|
||||
series={series}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DiskFilesystem } from "@/main-axios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/dropdown-menu";
|
||||
|
||||
export function FilesystemPicker({
|
||||
filesystems,
|
||||
value,
|
||||
onChange,
|
||||
align = "end",
|
||||
className,
|
||||
}: {
|
||||
filesystems: DiskFilesystem[];
|
||||
value: string | null;
|
||||
onChange: (mount: string) => void;
|
||||
align?: "start" | "end";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (filesystems.length <= 1) return null;
|
||||
|
||||
const selected = filesystems.find((fs) => fs.mount === value);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"flex max-w-[160px] items-center gap-1 border border-border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
title={t("hostMetrics.selectFilesystem")}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selected?.label || selected?.mount || value || "-"}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
className="w-auto min-w-[220px] max-w-[min(360px,calc(100vw-2rem))]"
|
||||
>
|
||||
{filesystems.map((fs) => (
|
||||
<DropdownMenuItem
|
||||
key={fs.mount}
|
||||
onSelect={() => onChange(fs.mount)}
|
||||
className="gap-3"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
fs.mount === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{fs.label || fs.mount}
|
||||
</span>
|
||||
{fs.label && (
|
||||
<span className="max-w-24 truncate font-mono text-[10px] text-muted-foreground">
|
||||
{fs.mount}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
|
||||
{fs.usedHuman && fs.totalHuman
|
||||
? `${fs.usedHuman}/${fs.totalHuman}`
|
||||
: "N/A"}
|
||||
{fs.percent != null ? ` · ${fs.percent}%` : ""}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function FirewallCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const fw = metrics?.firewall;
|
||||
|
||||
const totalRules =
|
||||
fw?.chains?.reduce((sum, c) => sum + (c.rules?.length ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.firewall.title")}
|
||||
icon={<ShieldCheck className="size-3.5" />}
|
||||
>
|
||||
{!fw || fw.type === "none" ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.firewall.noData")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-semibold uppercase tracking-wide ${
|
||||
fw.status === "active"
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{fw.status === "inactive"
|
||||
? t("hostMetrics.firewall.inactive")
|
||||
: fw.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{fw.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<span className="font-bold text-foreground">
|
||||
{fw.chains?.length ?? 0}
|
||||
</span>{" "}
|
||||
{t("hostMetrics.firewall.chains")}
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-bold text-foreground">{totalRules}</span>{" "}
|
||||
{t("hostMetrics.firewall.rules")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useId } from "react";
|
||||
import { Gpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import type { GpuDevice, GpuProcess } from "@/types/stats-widgets";
|
||||
import { MiniStat, RadialGauge, Sparkline, StatRow } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import type { GpuHistories, GpuHistory } from "./gpu-history";
|
||||
|
||||
const NA = "N/A";
|
||||
const EMPTY_HISTORY: GpuHistory = { utilization: [], memory: [] };
|
||||
|
||||
function formatGiB(mib: number | null): string {
|
||||
return mib === null ? NA : (mib / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
function formatVram(gpu: GpuDevice): string {
|
||||
return gpu.memoryUsedMiB === null || gpu.memoryTotalMiB === null
|
||||
? NA
|
||||
: `${formatGiB(gpu.memoryUsedMiB)}/${formatGiB(gpu.memoryTotalMiB)}G`;
|
||||
}
|
||||
|
||||
function formatWhole(value: number | null, unit: string): string {
|
||||
return value === null ? NA : `${Math.round(value)}${unit}`;
|
||||
}
|
||||
|
||||
function formatPower(gpu: GpuDevice): string {
|
||||
if (gpu.powerDrawWatts === null && gpu.powerLimitWatts === null) return NA;
|
||||
return `${formatWhole(gpu.powerDrawWatts, "")}/${formatWhole(gpu.powerLimitWatts, "")}W`;
|
||||
}
|
||||
|
||||
function processVram(process: GpuProcess): string {
|
||||
const vram =
|
||||
process.memoryUsedMiB === null
|
||||
? NA
|
||||
: `${formatGiB(process.memoryUsedMiB)}G`;
|
||||
return process.gpuIndex === null ? vram : `${vram} · GPU ${process.gpuIndex}`;
|
||||
}
|
||||
|
||||
function executableName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() || path;
|
||||
}
|
||||
|
||||
/** A captioned 0-100% sparkline, so the two trends under each GPU are told apart. */
|
||||
function TrendSparkline({
|
||||
caption,
|
||||
data,
|
||||
colorClassName,
|
||||
}: {
|
||||
caption: string;
|
||||
data: number[];
|
||||
colorClassName?: string;
|
||||
}) {
|
||||
const captionId = useId();
|
||||
|
||||
return (
|
||||
<figure aria-labelledby={captionId} className="flex min-w-0 flex-col gap-1">
|
||||
<figcaption
|
||||
id={captionId}
|
||||
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
|
||||
>
|
||||
{caption}
|
||||
</figcaption>
|
||||
<Sparkline
|
||||
data={data}
|
||||
domain={[0, 100]}
|
||||
height={36}
|
||||
colorClassName={colorClassName}
|
||||
/>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuDeviceRow({
|
||||
gpu,
|
||||
history,
|
||||
}: {
|
||||
gpu: GpuDevice;
|
||||
history: GpuHistory;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-1.5 text-xs">
|
||||
<span className="font-mono text-muted-foreground">#{gpu.index}</span>
|
||||
<span className="truncate font-semibold">{gpu.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge
|
||||
value={gpu.utilizationPercent}
|
||||
size={96}
|
||||
caption={t("hostMetrics.gpu.utilization")}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.memory")}
|
||||
value={formatVram(gpu)}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.temperature")}
|
||||
value={formatWhole(gpu.temperatureCelsius, "°C")}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.power")}
|
||||
value={formatPower(gpu)}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.gpu.fan")}
|
||||
value={formatWhole(gpu.fanPercent, "%")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<TrendSparkline
|
||||
caption={t("hostMetrics.gpu.utilization")}
|
||||
data={[...history.utilization, gpu.utilizationPercent ?? 0]}
|
||||
/>
|
||||
<TrendSparkline
|
||||
caption={t("hostMetrics.gpu.memory")}
|
||||
data={[...history.memory, gpu.memoryPercent ?? 0]}
|
||||
colorClassName="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GpuCard({
|
||||
metrics,
|
||||
gpuHistories,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
gpuHistories: GpuHistories;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const gpus = metrics?.gpu?.gpus ?? [];
|
||||
const processes = metrics?.gpu?.processes ?? [];
|
||||
const driverVersion = gpus[0]?.driverVersion;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.gpu.title")}
|
||||
icon={<Gpu className="size-3.5" />}
|
||||
action={
|
||||
driverVersion ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{t("hostMetrics.gpu.driver", { version: driverVersion })}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scroll={gpus.length > 2 || processes.length > 6}
|
||||
scrollMax={520}
|
||||
>
|
||||
{gpus.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.gpu.noDevice")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{gpus.map((gpu) => (
|
||||
<GpuDeviceRow
|
||||
key={gpu.index}
|
||||
gpu={gpu}
|
||||
history={gpuHistories[gpu.index] ?? EMPTY_HISTORY}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="mb-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostMetrics.gpu.processes")}
|
||||
</span>
|
||||
{processes.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.gpu.noProcesses")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{processes.map((process) => (
|
||||
<StatRow
|
||||
key={`${process.gpuIndex}-${process.pid}`}
|
||||
label={
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span title={process.name} className="text-foreground">
|
||||
{executableName(process.name)}
|
||||
</span>
|
||||
<span className="font-mono text-[10px]">
|
||||
{process.pid}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
value={processVram(process)}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { UserCheck, UserX } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function LoginStatsCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const loginStats = metrics?.login_stats;
|
||||
const recentLogins = loginStats?.recentLogins ?? [];
|
||||
const failedLogins = loginStats?.failedLogins ?? [];
|
||||
|
||||
const allLogins = [
|
||||
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
|
||||
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
|
||||
.slice(0, 10);
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.loginStats")}
|
||||
icon={<UserCheck className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{allLogins.length === 0 ? (
|
||||
<span className="py-2 text-xs italic text-muted-foreground">
|
||||
{t("hostMetrics.noRecentLoginData")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allLogins.map((login, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between border p-2 ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{login.status === "failed" ? (
|
||||
<UserX className="size-3 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-3 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`truncate text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{login.user}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{new Date(login.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { RadialGauge, Sparkline, MiniStat } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
export function MemoryCard({
|
||||
metrics,
|
||||
history,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const percent = metrics?.memory?.percent ?? null;
|
||||
const usedGiB = metrics?.memory?.usedGiB ?? null;
|
||||
const totalGiB = metrics?.memory?.totalGiB ?? null;
|
||||
const freeGiB =
|
||||
usedGiB !== null && totalGiB !== null ? totalGiB - usedGiB : null;
|
||||
|
||||
const series: LineChartSeries[] = [
|
||||
{
|
||||
key: "mem",
|
||||
label: t("hostMetrics.memoryUsage"),
|
||||
color: "var(--chart-3)",
|
||||
data: rows?.map((r) => r.mem_percent) ?? [],
|
||||
},
|
||||
];
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.memoryUsage")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
action={
|
||||
hostId != null ? (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "live" && (
|
||||
<div className="flex items-center gap-4">
|
||||
<RadialGauge value={percent} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.memory")}
|
||||
value={
|
||||
usedGiB !== null && totalGiB !== null
|
||||
? `${usedGiB.toFixed(1)}/${totalGiB.toFixed(1)}G`
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<MiniStat
|
||||
caption={t("hostMetrics.free")}
|
||||
value={freeGiB !== null ? `${freeGiB.toFixed(1)}G` : "N/A"}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline
|
||||
data={[...history, percent ?? 0]}
|
||||
domain={[0, 100]}
|
||||
height={48}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length === 0 && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && rows.length > 0 && (
|
||||
<LineChart
|
||||
series={series}
|
||||
timestamps={timestamps}
|
||||
domain={[0, 100]}
|
||||
yFormatter={(v) => `${v.toFixed(0)}%`}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import { LineChart, type LineChartSeries } from "@/components/charts/LineChart";
|
||||
import {
|
||||
getMetricsHistory,
|
||||
type MetricsHistoryRow,
|
||||
} from "@/api/host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
function ifaceIcon(name: string) {
|
||||
if (/^(wl|wlan|wifi|ath)/.test(name))
|
||||
return <Wifi className="size-3 text-muted-foreground/60" />;
|
||||
if (/^(eth|en|enp|eno|ens)/.test(name))
|
||||
return <Cable className="size-3 text-muted-foreground/60" />;
|
||||
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
|
||||
return <Container className="size-3 text-muted-foreground/60" />;
|
||||
return <Network className="size-3 text-muted-foreground/60" />;
|
||||
}
|
||||
|
||||
function computeNetRates(rows: MetricsHistoryRow[]): {
|
||||
rxRates: Array<number | null>;
|
||||
txRates: Array<number | null>;
|
||||
} {
|
||||
const rxRates: Array<number | null> = [];
|
||||
const txRates: Array<number | null> = [];
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (i === 0) {
|
||||
rxRates.push(null);
|
||||
txRates.push(null);
|
||||
continue;
|
||||
}
|
||||
const prev = rows[i - 1];
|
||||
const curr = rows[i];
|
||||
const dtSec =
|
||||
(new Date(curr.ts).getTime() - new Date(prev.ts).getTime()) / 1000;
|
||||
if (
|
||||
dtSec < 0.5 ||
|
||||
prev.net_rx_bytes == null ||
|
||||
curr.net_rx_bytes == null ||
|
||||
prev.net_tx_bytes == null ||
|
||||
curr.net_tx_bytes == null
|
||||
) {
|
||||
rxRates.push(null);
|
||||
txRates.push(null);
|
||||
continue;
|
||||
}
|
||||
const rxDelta = curr.net_rx_bytes - prev.net_rx_bytes;
|
||||
const txDelta = curr.net_tx_bytes - prev.net_tx_bytes;
|
||||
rxRates.push(rxDelta < 0 ? null : rxDelta / dtSec);
|
||||
txRates.push(txDelta < 0 ? null : txDelta / dtSec);
|
||||
}
|
||||
return { rxRates, txRates };
|
||||
}
|
||||
|
||||
function formatBytes(bps: number): string {
|
||||
if (bps >= 1024 * 1024) return `${(bps / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bps >= 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
return `${Math.round(bps)} B/s`;
|
||||
}
|
||||
|
||||
export function NetworkCard({
|
||||
metrics,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<HistoryTab>("live");
|
||||
const [rows, setRows] = useState<MetricsHistoryRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "live" || hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRows(null);
|
||||
getMetricsHistory(hostId, { range: activeTab })
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setRows(res.rows);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, hostId]);
|
||||
|
||||
const interfaces = metrics?.network?.interfaces ?? [];
|
||||
|
||||
const { rxRates, txRates } = rows
|
||||
? computeNetRates(rows)
|
||||
: { rxRates: [], txRates: [] };
|
||||
const netMax = Math.max(
|
||||
rxRates.reduce<number>((m, v) => (v != null && v > m ? v : m), 0),
|
||||
txRates.reduce<number>((m, v) => (v != null && v > m ? v : m), 0),
|
||||
1024,
|
||||
);
|
||||
const timestamps = rows?.map((r) => r.ts) ?? [];
|
||||
const hasNetData = rxRates.some((v) => v !== null);
|
||||
|
||||
const rxSeries: LineChartSeries = {
|
||||
key: "rx",
|
||||
label: t("metricsHistory.download"),
|
||||
color: "var(--chart-2)",
|
||||
data: rxRates,
|
||||
};
|
||||
const txSeries: LineChartSeries = {
|
||||
key: "tx",
|
||||
label: t("metricsHistory.upload"),
|
||||
color: "var(--chart-5)",
|
||||
data: txRates,
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll={activeTab === "live"}
|
||||
action={
|
||||
hostId != null ? (
|
||||
<CardTimeTabs value={activeTab} onChange={setActiveTab} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{activeTab === "live" &&
|
||||
(interfaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6 text-muted-foreground">
|
||||
<WifiOff className="size-6 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t("hostMetrics.noInterfacesFound")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{interfaces.map((iface, i) => (
|
||||
<div
|
||||
key={`${iface.name}-${i}`}
|
||||
className="flex flex-col gap-1 border border-border bg-muted/30 p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{ifaceIcon(iface.name)}
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground/50"}`}
|
||||
/>
|
||||
<span className="font-mono text-sm font-bold">
|
||||
{iface.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="border border-border px-1.5 py-px text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{iface.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-mono text-[10px] text-muted-foreground">
|
||||
<span className="truncate">{iface.ip}</span>
|
||||
{(iface.rxRateBps != null || iface.txRateBps != null) && (
|
||||
<span className="shrink-0">
|
||||
↓{" "}
|
||||
{iface.rxRateBps == null
|
||||
? "—"
|
||||
: formatBytes(iface.rxRateBps)}{" "}
|
||||
/ ↑{" "}
|
||||
{iface.txRateBps == null
|
||||
? "—"
|
||||
: formatBytes(iface.txRateBps)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{activeTab !== "live" && (
|
||||
<>
|
||||
{loading && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && rows !== null && !hasNetData && (
|
||||
<div className="flex h-36 items-center justify-center text-xs text-muted-foreground">
|
||||
{t("metricsHistory.noData")}
|
||||
</div>
|
||||
)}
|
||||
{!loading && hasNetData && (
|
||||
<LineChart
|
||||
series={[rxSeries, txSeries]}
|
||||
timestamps={timestamps}
|
||||
domain={[0, netMax]}
|
||||
yFormatter={formatBytes}
|
||||
height={160}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Unplug, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics, ListeningPort } from "@/main-axios";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
function formatAddress(addr: string) {
|
||||
return addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
|
||||
}
|
||||
|
||||
function PortRow({ port }: { port: ListeningPort }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[3.5rem_3rem_1fr_4rem] gap-2 overflow-hidden border-b border-border/50 py-1 font-mono text-xs last:border-0">
|
||||
<span className="truncate font-bold text-accent-brand">
|
||||
{port.localPort}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{port.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate font-semibold">
|
||||
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
|
||||
</span>
|
||||
<span className="truncate text-right text-muted-foreground">
|
||||
{formatAddress(port.localAddress)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortsCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [proto, setProto] = useState<"all" | "tcp" | "udp">("all");
|
||||
const ports = useMemo(() => metrics?.ports?.ports ?? [], [metrics]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return ports.filter((p) => {
|
||||
if (proto !== "all" && p.protocol.toLowerCase() !== proto) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
String(p.localPort).includes(q) ||
|
||||
(p.process ?? "").toLowerCase().includes(q) ||
|
||||
String(p.pid ?? "").includes(q) ||
|
||||
p.localAddress.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [ports, query, proto]);
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.ports.title")}
|
||||
icon={<Unplug className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("hostMetrics.ports.search")}
|
||||
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<Select2
|
||||
value={proto}
|
||||
onChange={(e) => setProto(e.target.value as "all" | "tcp" | "udp")}
|
||||
className="h-7 border border-border bg-background px-1 text-xs"
|
||||
>
|
||||
<option value="all">{t("hostMetrics.ports.allProtocols")}</option>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</Select2>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-[3.5rem_3rem_1fr_4rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
|
||||
<span>{t("hostMetrics.ports.port")}</span>
|
||||
<span>{t("hostMetrics.ports.protocol")}</span>
|
||||
<span>{t("hostMetrics.ports.process")}</span>
|
||||
<span className="text-right">{t("hostMetrics.ports.address")}</span>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<span className="py-2 text-xs italic text-muted-foreground">
|
||||
{t("hostMetrics.ports.noData")}
|
||||
</span>
|
||||
) : (
|
||||
filtered.map((port, i) => (
|
||||
<PortRow
|
||||
key={`${port.protocol}-${port.localPort}-${i}`}
|
||||
port={port}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { StatRow } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function ProcessesCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const procs = metrics?.processes;
|
||||
const top = procs?.top ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.processes")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-4 text-xs">
|
||||
{procs?.total != null && (
|
||||
<span>
|
||||
<span className="font-bold text-foreground">{procs.total}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{t("hostMetrics.processesTotal")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{procs?.running != null && (
|
||||
<span>
|
||||
<span className="font-bold text-accent-brand">{procs.running}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{t("hostMetrics.processesRunning")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{top.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.noProcessesFound")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{top.slice(0, 10).map((p) => (
|
||||
<StatRow
|
||||
key={p.pid}
|
||||
label={p.command}
|
||||
value={`${p.cpu}% cpu · ${p.mem}% mem`}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { StatRow } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function SystemCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const system = metrics?.system;
|
||||
const uptime = metrics?.uptime;
|
||||
|
||||
const rows = [
|
||||
{ label: t("hostMetrics.hostname"), value: system?.hostname },
|
||||
{ label: t("hostMetrics.operatingSystem"), value: system?.os },
|
||||
{ label: t("hostMetrics.kernel"), value: system?.kernel },
|
||||
{ label: t("hostMetrics.architecture"), value: system?.arch },
|
||||
{ label: t("hostMetrics.uptime"), value: uptime?.formatted },
|
||||
].filter((r) => r.value);
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.systemInfo")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{rows.map(({ label, value }) => (
|
||||
<StatRow key={label} label={label} value={value} mono />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Thermometer } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { StatRow } from "@/components/charts";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
selectTemperatureSensor,
|
||||
temperaturePreferenceKey,
|
||||
} from "../temperature-preference";
|
||||
|
||||
function formatTemperature(value: number | null | undefined): string {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? `${value.toFixed(1)}°C`
|
||||
: "N/A";
|
||||
}
|
||||
|
||||
export function TemperatureCard({
|
||||
metrics,
|
||||
hostId,
|
||||
}: {
|
||||
metrics: ServerMetrics | null;
|
||||
hostId: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const temperature = metrics?.temperature;
|
||||
const sensors = temperature?.sensors ?? [];
|
||||
const [preferredLabel, setPreferredLabel] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setPreferredLabel(
|
||||
hostId === null
|
||||
? ""
|
||||
: (localStorage.getItem(temperaturePreferenceKey(hostId)) ?? ""),
|
||||
);
|
||||
}, [hostId]);
|
||||
|
||||
const preferredSensor = selectTemperatureSensor(sensors, preferredLabel);
|
||||
const displayedTemperature =
|
||||
preferredSensor?.celsius ?? temperature?.highestCelsius;
|
||||
const displayedLabel =
|
||||
preferredSensor?.label ?? t("hostMetrics.highestTemperature");
|
||||
|
||||
const chooseSensor = (label: string) => {
|
||||
setPreferredLabel(label);
|
||||
if (hostId === null) return;
|
||||
const key = temperaturePreferenceKey(hostId);
|
||||
if (label) localStorage.setItem(key, label);
|
||||
else localStorage.removeItem(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.temperature")}
|
||||
icon={<Thermometer className="size-3.5" />}
|
||||
scroll={sensors.length > 4}
|
||||
scrollMax={220}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<div className="text-3xl font-semibold tabular-nums">
|
||||
{formatTemperature(displayedTemperature)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{displayedLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sensors.length > 1 && (
|
||||
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<span>{t("hostMetrics.primaryTemperatureSensor")}</span>
|
||||
<select
|
||||
value={preferredSensor?.label ?? ""}
|
||||
onChange={(event) => chooseSensor(event.target.value)}
|
||||
className="h-8 border border-border bg-background px-2 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hostMetrics.highestTemperature")}</option>
|
||||
{sensors.map((sensor) => (
|
||||
<option key={sensor.label} value={sensor.label}>
|
||||
{sensor.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{sensors.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{sensors.map((sensor) => (
|
||||
<StatRow
|
||||
key={`${sensor.label}-${sensor.celsius}`}
|
||||
label={sensor.label}
|
||||
value={formatTemperature(sensor.celsius)}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
|
||||
export function UptimeCard({ metrics }: { metrics: ServerMetrics | null }) {
|
||||
const { t } = useTranslation();
|
||||
const uptime = metrics?.uptime;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("hostMetrics.uptime")}
|
||||
icon={<Clock className="size-3.5" />}
|
||||
>
|
||||
<div className="flex h-full flex-col justify-center gap-2">
|
||||
<span className="text-2xl font-bold leading-none text-accent-brand md:text-3xl">
|
||||
{uptime?.formatted ?? "N/A"}
|
||||
</span>
|
||||
{uptime?.seconds != null && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{Math.floor(uptime.seconds).toLocaleString()}{" "}
|
||||
{t("hostMetrics.seconds")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { GpuDevice } from "@/types/stats-widgets";
|
||||
|
||||
export interface GpuHistory {
|
||||
utilization: number[];
|
||||
memory: number[];
|
||||
}
|
||||
|
||||
/** Live sparkline series keyed by GPU index. */
|
||||
export type GpuHistories = Record<number, GpuHistory>;
|
||||
|
||||
/**
|
||||
* Appends one poll's readings to each GPU's series. GPUs missing from the poll
|
||||
* lose their series so a re-enumerated device never inherits stale samples.
|
||||
*/
|
||||
export function appendGpuHistories(
|
||||
prev: GpuHistories,
|
||||
gpus: GpuDevice[] | undefined,
|
||||
maxLength: number,
|
||||
): GpuHistories {
|
||||
const add = (arr: number[] | undefined, v: number | null) =>
|
||||
[...(arr ?? []), v ?? 0].slice(-maxLength);
|
||||
|
||||
const next: GpuHistories = {};
|
||||
for (const gpu of gpus ?? []) {
|
||||
next[gpu.index] = {
|
||||
utilization: add(prev[gpu.index]?.utilization, gpu.utilizationPercent),
|
||||
memory: add(prev[gpu.index]?.memory, gpu.memoryPercent),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import {
|
||||
defaultColSpanFor,
|
||||
defaultHeightFor,
|
||||
type HostMetricsCardId,
|
||||
} from "@/types/host-metrics";
|
||||
import { CpuCard } from "./CpuCard";
|
||||
import { MemoryCard } from "./MemoryCard";
|
||||
import { DiskCard } from "./DiskCard";
|
||||
import { NetworkCard } from "./NetworkCard";
|
||||
import { UptimeCard } from "./UptimeCard";
|
||||
import { SystemCard } from "./SystemCard";
|
||||
import { LoginStatsCard } from "./LoginStatsCard";
|
||||
import { PortsCard } from "./PortsCard";
|
||||
import { ProcessesCard } from "./ProcessesCard";
|
||||
import { FirewallCard } from "./FirewallCard";
|
||||
import { TemperatureCard } from "./TemperatureCard";
|
||||
import { GpuCard } from "./GpuCard";
|
||||
import type { GpuHistories } from "./gpu-history";
|
||||
import { ServiceManagerCard } from "./managers/ServiceManagerCard";
|
||||
import { ProcessInspectorCard } from "./managers/ProcessInspectorCard";
|
||||
import { PackageManagerCard } from "./managers/PackageManagerCard";
|
||||
import { CronManagerCard } from "./managers/CronManagerCard";
|
||||
import { SslManagerCard } from "./managers/SslManagerCard";
|
||||
import { FirewallManagerCard } from "./managers/FirewallManagerCard";
|
||||
import { UserManagerCard } from "./managers/UserManagerCard";
|
||||
import { HealthCheckCard } from "./managers/HealthCheckCard";
|
||||
import { LogViewerCard } from "./managers/LogViewerCard";
|
||||
import {
|
||||
TopMemoryCard,
|
||||
SystemdTimersCard,
|
||||
DiskBreakdownCard,
|
||||
} from "./managers/SimpleManagerCards";
|
||||
import { WireGuardManagerCard } from "./managers/WireGuardManagerCard";
|
||||
import { TailscaleManagerCard } from "./managers/TailscaleManagerCard";
|
||||
|
||||
export interface MetricCardHistories {
|
||||
cpu: number[];
|
||||
memory: number[];
|
||||
disk: number[];
|
||||
gpu: GpuHistories;
|
||||
}
|
||||
|
||||
export interface CardRenderContext {
|
||||
metrics: ServerMetrics | null;
|
||||
histories: MetricCardHistories;
|
||||
hostId: number | null;
|
||||
}
|
||||
|
||||
export interface CardDefinition {
|
||||
id: HostMetricsCardId;
|
||||
/** i18n key for the card's display label. */
|
||||
labelKey: string;
|
||||
kind: "metric" | "manager";
|
||||
render: (ctx: CardRenderContext) => ReactNode;
|
||||
}
|
||||
|
||||
type SimpleMetricCard = ComponentType<{
|
||||
metrics: ServerMetrics | null;
|
||||
hostId: number | null;
|
||||
}>;
|
||||
type HistoryMetricCard = ComponentType<{
|
||||
metrics: ServerMetrics | null;
|
||||
history: number[];
|
||||
hostId: number | null;
|
||||
}>;
|
||||
|
||||
function metricCard(
|
||||
id: HostMetricsCardId,
|
||||
labelKey: string,
|
||||
Comp: SimpleMetricCard,
|
||||
): CardDefinition {
|
||||
return {
|
||||
id,
|
||||
labelKey,
|
||||
kind: "metric",
|
||||
render: ({ metrics, hostId }) => <Comp metrics={metrics} hostId={hostId} />,
|
||||
};
|
||||
}
|
||||
|
||||
function historyCard(
|
||||
id: HostMetricsCardId,
|
||||
labelKey: string,
|
||||
Comp: HistoryMetricCard,
|
||||
series: "cpu" | "memory" | "disk",
|
||||
): CardDefinition {
|
||||
return {
|
||||
id,
|
||||
labelKey,
|
||||
kind: "metric",
|
||||
render: ({ metrics, histories, hostId }) => (
|
||||
<Comp metrics={metrics} history={histories[series]} hostId={hostId} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
type ManagerCardComp = ComponentType<{ hostId: number | null }>;
|
||||
|
||||
function managerCard(
|
||||
id: HostMetricsCardId,
|
||||
labelKey: string,
|
||||
Comp: ManagerCardComp,
|
||||
): CardDefinition {
|
||||
return {
|
||||
id,
|
||||
labelKey,
|
||||
kind: "manager",
|
||||
render: ({ hostId }) => <Comp hostId={hostId} />,
|
||||
};
|
||||
}
|
||||
|
||||
export const CARD_DEFINITIONS: Record<string, CardDefinition> = {
|
||||
cpu: historyCard("cpu", "hostMetrics.cpuUsage", CpuCard, "cpu"),
|
||||
memory: historyCard(
|
||||
"memory",
|
||||
"hostMetrics.memoryUsage",
|
||||
MemoryCard,
|
||||
"memory",
|
||||
),
|
||||
disk: historyCard("disk", "hostMetrics.diskUsage", DiskCard, "disk"),
|
||||
network: metricCard("network", "hostMetrics.networkInterfaces", NetworkCard),
|
||||
uptime: metricCard("uptime", "hostMetrics.uptime", UptimeCard),
|
||||
system: metricCard("system", "hostMetrics.systemInfo", SystemCard),
|
||||
login_stats: metricCard(
|
||||
"login_stats",
|
||||
"hostMetrics.loginStats",
|
||||
LoginStatsCard,
|
||||
),
|
||||
ports: metricCard("ports", "hostMetrics.ports.title", PortsCard),
|
||||
processes: metricCard("processes", "hostMetrics.processes", ProcessesCard),
|
||||
firewall: metricCard("firewall", "hostMetrics.firewall.title", FirewallCard),
|
||||
temperature: metricCard(
|
||||
"temperature",
|
||||
"hostMetrics.temperature",
|
||||
TemperatureCard,
|
||||
),
|
||||
gpu: {
|
||||
id: "gpu",
|
||||
labelKey: "hostMetrics.gpu.title",
|
||||
kind: "metric",
|
||||
render: ({ metrics, histories }) => (
|
||||
<GpuCard metrics={metrics} gpuHistories={histories.gpu} />
|
||||
),
|
||||
},
|
||||
service_manager: managerCard(
|
||||
"service_manager",
|
||||
"hostMetrics.managers.services",
|
||||
ServiceManagerCard,
|
||||
),
|
||||
process_inspector: managerCard(
|
||||
"process_inspector",
|
||||
"hostMetrics.managers.processInspector",
|
||||
ProcessInspectorCard,
|
||||
),
|
||||
log_viewer: managerCard(
|
||||
"log_viewer",
|
||||
"hostMetrics.managers.logViewer",
|
||||
LogViewerCard,
|
||||
),
|
||||
cron_manager: managerCard(
|
||||
"cron_manager",
|
||||
"hostMetrics.managers.cron",
|
||||
CronManagerCard,
|
||||
),
|
||||
package_manager: managerCard(
|
||||
"package_manager",
|
||||
"hostMetrics.managers.packages",
|
||||
PackageManagerCard,
|
||||
),
|
||||
ssl_manager: managerCard(
|
||||
"ssl_manager",
|
||||
"hostMetrics.managers.ssl",
|
||||
SslManagerCard,
|
||||
),
|
||||
firewall_manager: managerCard(
|
||||
"firewall_manager",
|
||||
"hostMetrics.managers.firewall",
|
||||
FirewallManagerCard,
|
||||
),
|
||||
user_manager: managerCard(
|
||||
"user_manager",
|
||||
"hostMetrics.managers.users",
|
||||
UserManagerCard,
|
||||
),
|
||||
health_check: managerCard(
|
||||
"health_check",
|
||||
"hostMetrics.managers.healthCheck",
|
||||
HealthCheckCard,
|
||||
),
|
||||
disk_breakdown: managerCard(
|
||||
"disk_breakdown",
|
||||
"hostMetrics.managers.diskBreakdown",
|
||||
DiskBreakdownCard,
|
||||
),
|
||||
systemd_timers: managerCard(
|
||||
"systemd_timers",
|
||||
"hostMetrics.managers.systemdTimers",
|
||||
SystemdTimersCard,
|
||||
),
|
||||
top_memory: managerCard(
|
||||
"top_memory",
|
||||
"hostMetrics.managers.topMemory",
|
||||
TopMemoryCard,
|
||||
),
|
||||
wireguard_manager: managerCard(
|
||||
"wireguard_manager",
|
||||
"hostMetrics.managers.wireguard",
|
||||
WireGuardManagerCard,
|
||||
),
|
||||
tailscale_manager: managerCard(
|
||||
"tailscale_manager",
|
||||
"hostMetrics.managers.tailscale",
|
||||
TailscaleManagerCard,
|
||||
),
|
||||
};
|
||||
|
||||
/** All card ids implemented and available in the Add tray. */
|
||||
export const IMPLEMENTED_CARD_IDS = Object.keys(
|
||||
CARD_DEFINITIONS,
|
||||
) as HostMetricsCardId[];
|
||||
|
||||
export function getCardDefinition(id: string): CardDefinition | undefined {
|
||||
return CARD_DEFINITIONS[id];
|
||||
}
|
||||
|
||||
export { defaultColSpanFor, defaultHeightFor };
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Clock4, Plus, Trash2, Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
interface CronEntry {
|
||||
raw: string;
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export function CronManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
entries: CronEntry[];
|
||||
}>(hostId, "cron");
|
||||
const [entries, setEntries] = useState<CronEntry[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// Keep the real array index so edits/removes target the right entry even when
|
||||
// the list is filtered by the search box.
|
||||
const visible = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return entries
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(
|
||||
({ entry }) =>
|
||||
!q ||
|
||||
entry.schedule.toLowerCase().includes(q) ||
|
||||
entry.command.toLowerCase().includes(q),
|
||||
);
|
||||
}, [entries, query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.entries) {
|
||||
setEntries(data.entries);
|
||||
setDirty(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const update = (i: number, patch: Partial<CronEntry>) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e, idx) => (idx === i ? { ...e, ...patch } : e)),
|
||||
);
|
||||
setDirty(true);
|
||||
};
|
||||
const remove = (i: number) => {
|
||||
setEntries((prev) => prev.filter((_, idx) => idx !== i));
|
||||
setDirty(true);
|
||||
};
|
||||
const add = () => {
|
||||
setEntries((prev) => [
|
||||
...prev,
|
||||
{ raw: "", enabled: true, schedule: "0 * * * *", command: "" },
|
||||
]);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (hostId == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"cron",
|
||||
{ entries },
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success(t("hostMetrics.managers.cronSaved"));
|
||||
setDirty(false);
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.cron")}
|
||||
icon={<Clock4 className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
headerExtra={
|
||||
<>
|
||||
<Button variant="ghost" size="xs" onClick={add}>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
>
|
||||
<Save className="size-3" />
|
||||
{t("hostMetrics.managers.save")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
empty={!loading && entries.length === 0}
|
||||
>
|
||||
{entries.length > 5 && (
|
||||
<ManagerSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
count={visible.length}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{visible.map(({ entry: e, index: i }) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={e.enabled}
|
||||
onChange={(ev) => update(i, { enabled: ev.target.checked })}
|
||||
className="accent-accent-brand"
|
||||
title={t("hostMetrics.managers.enabled")}
|
||||
/>
|
||||
<input
|
||||
value={e.schedule}
|
||||
onChange={(ev) => update(i, { schedule: ev.target.value })}
|
||||
placeholder="0 * * * *"
|
||||
className="h-6 w-28 border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="ml-auto text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={e.command}
|
||||
onChange={(ev) => update(i, { command: ev.target.value })}
|
||||
placeholder={t("hostMetrics.managers.command")}
|
||||
className="h-6 w-full border border-border bg-background px-1.5 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { managerPost, type FirewallChain } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface FirewallData {
|
||||
type: "iptables" | "nftables" | "none";
|
||||
status: string;
|
||||
chains: FirewallChain[];
|
||||
}
|
||||
|
||||
export function FirewallManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<FirewallData>(
|
||||
hostId,
|
||||
"firewall",
|
||||
);
|
||||
const [proto, setProto] = useState<"tcp" | "udp">("tcp");
|
||||
const [port, setPort] = useState("");
|
||||
const [target, setTarget] = useState<"ACCEPT" | "DROP" | "REJECT">("ACCEPT");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const rule = async (op: "add" | "delete") => {
|
||||
if (hostId == null) return;
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
toast.error(t("hostMetrics.managers.invalidPort"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"firewall",
|
||||
{ op, protocol: proto, port: portNum, target },
|
||||
"rule",
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success(t("hostMetrics.managers.ruleApplied"));
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const persist = async () => {
|
||||
if (hostId == null) return;
|
||||
setBusy(true);
|
||||
toast.loading(t("hostMetrics.managers.working"), { id: "fw-persist" });
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"firewall",
|
||||
{},
|
||||
"persist",
|
||||
);
|
||||
toast[res.success ? "success" : "error"](
|
||||
res.success
|
||||
? t("hostMetrics.managers.firewallPersisted")
|
||||
: t("hostMetrics.managers.actionFailed"),
|
||||
{ id: "fw-persist", description: res.output?.slice(-200) },
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message, { id: "fw-persist" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chains = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return (data?.chains ?? [])
|
||||
.map((c) => ({
|
||||
...c,
|
||||
rules: c.rules.filter(
|
||||
(r) =>
|
||||
!q ||
|
||||
r.target.toLowerCase().includes(q) ||
|
||||
(r.dport ?? "").includes(q) ||
|
||||
r.protocol.toLowerCase().includes(q) ||
|
||||
r.source.toLowerCase().includes(q),
|
||||
),
|
||||
}))
|
||||
.filter((c) => c.rules.length > 0 || !q);
|
||||
}, [data?.chains, query]);
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.firewall")}
|
||||
icon={<ShieldCheck className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
headerExtra={
|
||||
<>
|
||||
<span className="text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||
{data?.type ?? ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy || data?.type === "none"}
|
||||
onClick={persist}
|
||||
title={t("hostMetrics.managers.firewallPersist")}
|
||||
>
|
||||
<Save className="size-3" />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostMetrics.managers.addInputRule")}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select2
|
||||
value={proto}
|
||||
onChange={(e) => setProto(e.target.value as "tcp" | "udp")}
|
||||
className="h-7 border border-border bg-background px-1 text-xs"
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</Select2>
|
||||
<input
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder={t("hostMetrics.ports.port")}
|
||||
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Select2
|
||||
value={target}
|
||||
onChange={(e) =>
|
||||
setTarget(e.target.value as "ACCEPT" | "DROP" | "REJECT")
|
||||
}
|
||||
className="h-7 border border-border bg-background px-1 text-xs"
|
||||
>
|
||||
<option>ACCEPT</option>
|
||||
<option>DROP</option>
|
||||
<option>REJECT</option>
|
||||
</Select2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => rule("add")}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => rule("delete")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[10px] text-yellow-500">
|
||||
{t("hostMetrics.managers.firewallWarning")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ManagerSearch value={query} onChange={setQuery} />
|
||||
|
||||
{chains.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hostMetrics.firewall.noData")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{chains.map((chain) => (
|
||||
<div key={chain.name} className="flex flex-col">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-foreground">
|
||||
{chain.name}
|
||||
</span>
|
||||
{chain.policy && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hostMetrics.firewall.policy")}: {chain.policy}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{chain.rules.length === 0 ? (
|
||||
<span className="text-[10px] italic text-muted-foreground/60">
|
||||
{t("hostMetrics.firewall.noRules")}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
|
||||
<span>{t("hostMetrics.firewall.action")}</span>
|
||||
<span>{t("hostMetrics.firewall.protocol")}</span>
|
||||
<span>{t("hostMetrics.firewall.port")}</span>
|
||||
<span>{t("hostMetrics.firewall.source")}</span>
|
||||
</div>
|
||||
{chain.rules.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-4 gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
|
||||
>
|
||||
<span className="font-bold">{r.target}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{r.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span>{r.dport ?? "—"}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{r.source}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
HeartPulse,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Play,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Sparkline } from "@/components/charts";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tcp" | "http";
|
||||
target: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
interface HealthResult {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
interface HistoryRow {
|
||||
checkId: string;
|
||||
ts: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
interface HealthData {
|
||||
checks: HealthCheck[];
|
||||
results: HealthResult[];
|
||||
history: HistoryRow[];
|
||||
}
|
||||
|
||||
function newCheck(): HealthCheck {
|
||||
return {
|
||||
id: `c_${Math.random().toString(36).slice(2, 9)}`,
|
||||
name: "",
|
||||
type: "tcp",
|
||||
target: "",
|
||||
port: 80,
|
||||
};
|
||||
}
|
||||
|
||||
export function HealthCheckCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<HealthData>(
|
||||
hostId,
|
||||
"health",
|
||||
);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<HealthCheck[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.checks && !editing) setDraft(data.checks);
|
||||
}, [data?.checks, editing]);
|
||||
|
||||
const byCheck = useMemo(() => {
|
||||
const map = new Map<string, HistoryRow[]>();
|
||||
for (const h of data?.history ?? []) {
|
||||
const arr = map.get(h.checkId) ?? [];
|
||||
arr.push(h);
|
||||
map.set(h.checkId, arr);
|
||||
}
|
||||
return map;
|
||||
}, [data?.history]);
|
||||
|
||||
const save = async () => {
|
||||
if (hostId == null) return;
|
||||
for (const c of draft) {
|
||||
if (!c.name.trim() || !c.target.trim()) {
|
||||
toast.error(t("hostMetrics.managers.healthMissingFields"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean }>(
|
||||
hostId,
|
||||
"health",
|
||||
{ checks: draft },
|
||||
"config",
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success(t("hostMetrics.managers.healthSaved"));
|
||||
setEditing(false);
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(t("hostMetrics.managers.actionFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
if (hostId == null) return;
|
||||
setRunning(true);
|
||||
try {
|
||||
await managerPost(hostId, "health", {}, "run");
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = (id: string, patch: Partial<HealthCheck>) =>
|
||||
setDraft((d) => d.map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.healthCheck")}
|
||||
icon={<HeartPulse className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
headerExtra={
|
||||
editing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setDraft(data?.checks ?? []);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={running || (data?.checks?.length ?? 0) === 0}
|
||||
onClick={runNow}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("hostMetrics.managers.healthRun")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="xs" onClick={() => setEditing(true)}>
|
||||
<Pencil className="size-3" />
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{draft.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex flex-col gap-1.5 border border-border bg-muted/20 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={c.name}
|
||||
onChange={(e) => updateDraft(c.id, { name: e.target.value })}
|
||||
placeholder={t("hostMetrics.managers.healthName")}
|
||||
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Select2
|
||||
value={c.type}
|
||||
onChange={(e) =>
|
||||
updateDraft(c.id, {
|
||||
type: e.target.value as "tcp" | "http",
|
||||
})
|
||||
}
|
||||
className="h-7 border border-border bg-background px-1 text-xs"
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="http">HTTP</option>
|
||||
</Select2>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDraft((d) => d.filter((x) => x.id !== c.id))
|
||||
}
|
||||
className="flex size-7 items-center justify-center text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={c.target}
|
||||
onChange={(e) =>
|
||||
updateDraft(c.id, { target: e.target.value })
|
||||
}
|
||||
placeholder={t("hostMetrics.managers.healthTarget")}
|
||||
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{c.type === "tcp" ? (
|
||||
<input
|
||||
type="number"
|
||||
value={c.port ?? ""}
|
||||
onChange={(e) =>
|
||||
updateDraft(c.id, { port: Number(e.target.value) })
|
||||
}
|
||||
placeholder={t("hostMetrics.ports.port")}
|
||||
className="h-7 w-16 border border-border bg-background px-1.5 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={c.path ?? ""}
|
||||
onChange={(e) =>
|
||||
updateDraft(c.id, { path: e.target.value })
|
||||
}
|
||||
placeholder="/health"
|
||||
className="h-7 w-24 border border-border bg-background px-1.5 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => setDraft((d) => [...d, newCheck()])}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("hostMetrics.managers.healthAddCheck")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (data?.checks?.length ?? 0) === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("hostMetrics.managers.noHealthChecks")}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Plus className="size-3" />
|
||||
{t("hostMetrics.managers.healthAddCheck")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{(data?.checks ?? []).map((check) => {
|
||||
const result = data?.results?.find((r) => r.checkId === check.id);
|
||||
const hist = (byCheck.get(check.id) ?? []).slice().reverse();
|
||||
const upPct =
|
||||
hist.length > 0
|
||||
? Math.round(
|
||||
(hist.filter((h) => h.ok).length / hist.length) * 100,
|
||||
)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={check.id}
|
||||
className="flex flex-col gap-1 border border-border bg-muted/20 p-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{result?.ok ? (
|
||||
<CheckCircle2 className="size-3.5 text-accent-brand" />
|
||||
) : (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="text-xs font-semibold">{check.name}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/60">
|
||||
{check.type === "tcp"
|
||||
? `${check.target}:${check.port}`
|
||||
: check.target}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{result?.latencyMs != null ? `${result.latencyMs}ms` : "—"}
|
||||
{upPct != null ? ` · ${upPct}%` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{hist.length > 1 && (
|
||||
<Sparkline
|
||||
data={hist.map((h) => h.latencyMs ?? 0)}
|
||||
height={28}
|
||||
showLastDot={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { managerGet, managerGetSub } from "@/main-axios";
|
||||
import { extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface LogFiles {
|
||||
common: string[];
|
||||
files: string[];
|
||||
}
|
||||
|
||||
type Mode = "file" | "unit";
|
||||
|
||||
export function LogViewerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<Mode>("file");
|
||||
const [path, setPath] = useState<string>("/var/log/syslog");
|
||||
const [unit, setUnit] = useState<string>("");
|
||||
const [customPath, setCustomPath] = useState("");
|
||||
const [lines, setLines] = useState(300);
|
||||
const [content, setContent] = useState("");
|
||||
const [grep, setGrep] = useState("");
|
||||
const [follow, setFollow] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const bodyRef = useRef<HTMLPreElement | null>(null);
|
||||
const contentRef = useRef(content);
|
||||
contentRef.current = content;
|
||||
|
||||
// Load the host's actual log files once.
|
||||
useEffect(() => {
|
||||
if (hostId == null) return;
|
||||
managerGetSub<LogFiles>(hostId, "logs", "files")
|
||||
.then((res) => {
|
||||
const merged = Array.from(
|
||||
new Set([...(res.common ?? []), ...(res.files ?? [])]),
|
||||
);
|
||||
setFiles(merged);
|
||||
if (merged.length && !merged.includes(path)) setPath(merged[0]);
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId]);
|
||||
|
||||
const fetchLog = useCallback(async () => {
|
||||
if (hostId == null) return false;
|
||||
const params: Record<string, string | number> = { lines };
|
||||
if (mode === "unit") {
|
||||
if (!unit.trim()) return false;
|
||||
params.unit = unit.trim();
|
||||
} else {
|
||||
const p = customPath.trim() || path;
|
||||
if (!p) return false;
|
||||
params.path = p;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await managerGet<{ content: string }>(hostId, "logs", params);
|
||||
const next = res.content || "";
|
||||
const changed = next !== contentRef.current;
|
||||
if (changed) {
|
||||
contentRef.current = next;
|
||||
setContent(next);
|
||||
requestAnimationFrame(() => {
|
||||
if (bodyRef.current)
|
||||
bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
} catch (e) {
|
||||
setError(extractError(e).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [customPath, hostId, lines, mode, path, unit]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLog();
|
||||
// Custom paths are applied on blur/manual refresh, not on every keystroke.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId, mode, path, unit, lines]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchLog,
|
||||
{
|
||||
minIntervalMs: 3_000,
|
||||
maxIntervalMs: 18_000,
|
||||
stablePollsPerStep: 2,
|
||||
maxRequestDutyCycle: 0.2,
|
||||
},
|
||||
follow,
|
||||
{ runImmediately: false },
|
||||
);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
if (!grep) return content;
|
||||
const needle = grep.toLowerCase();
|
||||
return content
|
||||
.split("\n")
|
||||
.filter((l) => l.toLowerCase().includes(needle))
|
||||
.join("\n");
|
||||
}, [content, grep]);
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.logViewer")}
|
||||
icon={<ScrollText className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error ? { message: error } : null}
|
||||
onRefresh={fetchLog}
|
||||
headerExtra={
|
||||
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={follow}
|
||||
onChange={(e) => setFollow(e.target.checked)}
|
||||
className="accent-accent-brand"
|
||||
/>
|
||||
{t("hostMetrics.managers.follow")}
|
||||
</label>
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Select2
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as Mode)}
|
||||
className="h-7 border border-border bg-background px-1 text-[11px]"
|
||||
>
|
||||
<option value="file">{t("hostMetrics.managers.logFile")}</option>
|
||||
<option value="unit">{t("hostMetrics.managers.logUnit")}</option>
|
||||
</Select2>
|
||||
{mode === "file" ? (
|
||||
<Select2
|
||||
value={customPath ? "" : path}
|
||||
onChange={(e) => {
|
||||
setCustomPath("");
|
||||
setPath(e.target.value);
|
||||
}}
|
||||
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{files.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
) : (
|
||||
<input
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
onBlur={fetchLog}
|
||||
placeholder="nginx.service"
|
||||
className="h-7 flex-1 border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
<Select2
|
||||
value={lines}
|
||||
onChange={(e) => setLines(Number(e.target.value))}
|
||||
className="h-7 border border-border bg-background px-1 text-[11px]"
|
||||
>
|
||||
{[100, 300, 500, 1000, 2000].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
{mode === "file" && (
|
||||
<input
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
onBlur={() => customPath && fetchLog()}
|
||||
placeholder={t("hostMetrics.managers.logCustomPath")}
|
||||
className="mb-2 h-7 w-full border border-border bg-background px-2 font-mono text-[11px] outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
value={grep}
|
||||
onChange={(e) => setGrep(e.target.value)}
|
||||
placeholder={t("hostMetrics.managers.logGrep")}
|
||||
className="mb-2 h-7 w-full border border-border bg-background px-2 text-[11px] outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
|
||||
<pre
|
||||
ref={bodyRef}
|
||||
className="max-h-[320px] overflow-auto whitespace-pre-wrap break-all border border-border/50 bg-muted/20 p-2 font-mono text-[10px] leading-relaxed"
|
||||
>
|
||||
{shown || t("hostMetrics.managers.noLogData")}
|
||||
</pre>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { RefreshCw, AlertTriangle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { MetricCard } from "@/components/metric-card";
|
||||
import type { ManagerError } from "./useManagerData";
|
||||
|
||||
export function ManagerCardShell({
|
||||
title,
|
||||
icon,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
empty,
|
||||
emptyMessage,
|
||||
children,
|
||||
headerExtra,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
loading: boolean;
|
||||
error: ManagerError | null;
|
||||
onRefresh: () => void;
|
||||
empty?: boolean;
|
||||
emptyMessage?: string;
|
||||
children: ReactNode;
|
||||
headerExtra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MetricCard
|
||||
title={title}
|
||||
icon={icon}
|
||||
scroll
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
{headerExtra}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title={t("hostMetrics.refresh")}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<AlertTriangle className="size-6 text-yellow-500" />
|
||||
<span className="text-xs text-muted-foreground">{error.message}</span>
|
||||
{error.code === "SUDO_REQUIRED" && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("hostMetrics.managers.sudoHint")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="mt-1"
|
||||
>
|
||||
{t("hostMetrics.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : empty ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-muted-foreground/50">
|
||||
{emptyMessage ?? t("hostMetrics.managers.noData")}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Compact search row shared by manager cards. Optional `extra` renders to the
|
||||
* right (filters, action buttons) and `count` shows a result tally.
|
||||
*/
|
||||
export function ManagerSearch({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
count,
|
||||
extra,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
count?: number;
|
||||
extra?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? t("hostMetrics.managers.filter")}
|
||||
className="h-7 w-full border border-border bg-background pl-7 pr-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
{count != null && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Package, ArrowUpCircle } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
interface UpgradablePackage {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
}
|
||||
|
||||
export function PackageManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
pkg: string | null;
|
||||
upgradable: UpgradablePackage[];
|
||||
}>(hostId, "packages");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const all = useMemo(() => data?.upgradable ?? [], [data?.upgradable]);
|
||||
const pkgs = useMemo(
|
||||
() =>
|
||||
all.filter(
|
||||
(p) => !filter || p.name.toLowerCase().includes(filter.toLowerCase()),
|
||||
),
|
||||
[all, filter],
|
||||
);
|
||||
|
||||
const run = async (action: string, pkg?: string) => {
|
||||
if (hostId == null) return;
|
||||
setBusy(pkg ?? action);
|
||||
toast.loading(t("hostMetrics.managers.working"), { id: "pkg-op" });
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"packages",
|
||||
{ action, pkg },
|
||||
"action",
|
||||
);
|
||||
toast[res.success ? "success" : "error"](
|
||||
res.success
|
||||
? t("hostMetrics.managers.actionDone", { name: pkg ?? "upgrade" })
|
||||
: t("hostMetrics.managers.actionFailed"),
|
||||
{ id: "pkg-op", description: res.output?.slice(-200) },
|
||||
);
|
||||
if (res.success) refresh();
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message, { id: "pkg-op" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.packages")}
|
||||
icon={<Package className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && all.length === 0}
|
||||
emptyMessage={t("hostMetrics.managers.allUpToDate")}
|
||||
headerExtra={
|
||||
all.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={!!busy}
|
||||
onClick={() => run("upgrade-all")}
|
||||
>
|
||||
<ArrowUpCircle className="size-3" />
|
||||
{t("hostMetrics.managers.upgradeAll")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{all.length > 5 && (
|
||||
<ManagerSearch
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
count={pkgs.length}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{pkgs.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-semibold">{p.name}</span>
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{p.currentVersion ? `${p.currentVersion} → ` : ""}
|
||||
{p.newVersion ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy === p.name}
|
||||
onClick={() => run("install", p.name)}
|
||||
>
|
||||
{t("hostMetrics.managers.update")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ListTree, X, Skull } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
user: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
command: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
type SortKey = "cpu" | "mem" | "pid";
|
||||
type Signal = "TERM" | "KILL";
|
||||
|
||||
export function ProcessInspectorCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
processes: ProcessRow[];
|
||||
}>(hostId, "processes");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("cpu");
|
||||
const [tree, setTree] = useState(false);
|
||||
const [busy, setBusy] = useState<number | null>(null);
|
||||
|
||||
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.toLowerCase();
|
||||
const list = all.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.args.toLowerCase().includes(q) ||
|
||||
p.command.toLowerCase().includes(q) ||
|
||||
String(p.pid).includes(q) ||
|
||||
p.user.toLowerCase().includes(q),
|
||||
);
|
||||
return [...list].sort((a, b) => {
|
||||
if (sort === "pid") return a.pid - b.pid;
|
||||
return b[sort] - a[sort];
|
||||
});
|
||||
}, [all, filter, sort]);
|
||||
|
||||
// Build a depth map for the tree view from ppid links (only among visible).
|
||||
const treeRows = useMemo(() => {
|
||||
if (!tree) return null;
|
||||
const byId = new Map(filtered.map((p) => [p.pid, p]));
|
||||
const children = new Map<number, ProcessRow[]>();
|
||||
const roots: ProcessRow[] = [];
|
||||
for (const p of filtered) {
|
||||
if (p.ppid && byId.has(p.ppid)) {
|
||||
const arr = children.get(p.ppid) ?? [];
|
||||
arr.push(p);
|
||||
children.set(p.ppid, arr);
|
||||
} else {
|
||||
roots.push(p);
|
||||
}
|
||||
}
|
||||
const out: Array<{ proc: ProcessRow; depth: number }> = [];
|
||||
const walk = (p: ProcessRow, depth: number) => {
|
||||
out.push({ proc: p, depth });
|
||||
for (const c of children.get(p.pid) ?? []) walk(c, depth + 1);
|
||||
};
|
||||
roots.forEach((r) => walk(r, 0));
|
||||
return out;
|
||||
}, [tree, filtered]);
|
||||
|
||||
const kill = async (pid: number, signal: Signal) => {
|
||||
if (hostId == null) return;
|
||||
setBusy(pid);
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"processes",
|
||||
{ pid, signal },
|
||||
"signal",
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success(t("hostMetrics.managers.signalSent", { pid }));
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const rows = treeRows
|
||||
? treeRows.slice(0, 200)
|
||||
: filtered.slice(0, 200).map((proc) => ({ proc, depth: 0 }));
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.processInspector")}
|
||||
icon={<ListTree className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && filtered.length === 0}
|
||||
headerExtra={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setTree((v) => !v)}
|
||||
className={`flex h-6 items-center gap-1 border px-1.5 text-[10px] transition-colors ${
|
||||
tree
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<ListTree className="size-3" />
|
||||
{t("hostMetrics.managers.tree")}
|
||||
</button>
|
||||
<Select2
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortKey)}
|
||||
disabled={tree}
|
||||
className="h-6 border border-border bg-background px-1 text-[10px] disabled:opacity-40"
|
||||
>
|
||||
<option value="cpu">CPU</option>
|
||||
<option value="mem">MEM</option>
|
||||
<option value="pid">PID</option>
|
||||
</Select2>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ManagerSearch
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
count={filtered.length}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] gap-2 border-b border-border pb-1 text-[10px] font-bold uppercase text-muted-foreground">
|
||||
<span>PID</span>
|
||||
<span>CPU</span>
|
||||
<span>MEM</span>
|
||||
<span>CMD</span>
|
||||
<span />
|
||||
</div>
|
||||
{rows.map(({ proc: p, depth }) => (
|
||||
<div
|
||||
key={p.pid}
|
||||
className="relative grid grid-cols-[3rem_2.5rem_2.5rem_1fr_1.5rem] items-center gap-2 border-b border-border/50 py-1 font-mono text-xs last:border-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{p.pid}</span>
|
||||
<span className="font-bold text-accent-brand">
|
||||
{p.cpu.toFixed(0)}%
|
||||
</span>
|
||||
<span>{p.mem.toFixed(0)}%</span>
|
||||
<span
|
||||
className="truncate"
|
||||
title={`${p.user} · ${p.args}`}
|
||||
style={depth ? { paddingLeft: depth * 12 } : undefined}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<span className="text-muted-foreground/40">└ </span>
|
||||
)}
|
||||
{/* `comm` is capped at 15 chars by the kernel; the full command line is in args. */}
|
||||
{p.args || p.command}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => kill(p.pid, "TERM")}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
kill(p.pid, "KILL");
|
||||
}}
|
||||
disabled={busy === p.pid}
|
||||
title={t("hostMetrics.managers.killHint")}
|
||||
className="flex size-5 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
{busy === p.pid ? (
|
||||
<Skull className="size-3" />
|
||||
) : (
|
||||
<X className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Cog, Play, Square, RotateCw, Power } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
interface SystemdService {
|
||||
unit: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function ServiceManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
services: SystemdService[];
|
||||
}>(hostId, "services");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const services = useMemo(
|
||||
() =>
|
||||
(data?.services ?? []).filter(
|
||||
(s) =>
|
||||
!filter ||
|
||||
s.unit.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(filter.toLowerCase()),
|
||||
),
|
||||
[data?.services, filter],
|
||||
);
|
||||
|
||||
const act = async (unit: string, action: string) => {
|
||||
if (hostId == null) return;
|
||||
setBusy(`${unit}:${action}`);
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"services",
|
||||
{ unit, action },
|
||||
"action",
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success(t("hostMetrics.managers.actionDone", { name: unit }));
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.services")}
|
||||
icon={<Cog className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && services.length === 0}
|
||||
>
|
||||
<ManagerSearch
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
count={services.length}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{services.map((s) => (
|
||||
<div
|
||||
key={s.unit}
|
||||
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${s.active === "active" ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-semibold" title={s.unit}>
|
||||
{s.unit.replace(/\.service$/, "")}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{s.sub}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<ActionBtn
|
||||
onClick={() => act(s.unit, "start")}
|
||||
busy={busy === `${s.unit}:start`}
|
||||
title={t("hostMetrics.managers.start")}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => act(s.unit, "restart")}
|
||||
busy={busy === `${s.unit}:restart`}
|
||||
title={t("hostMetrics.managers.restart")}
|
||||
>
|
||||
<RotateCw className="size-3" />
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() => act(s.unit, "stop")}
|
||||
busy={busy === `${s.unit}:stop`}
|
||||
title={t("hostMetrics.managers.stop")}
|
||||
>
|
||||
<Square className="size-3" />
|
||||
</ActionBtn>
|
||||
<ActionBtn
|
||||
onClick={() =>
|
||||
act(s.unit, s.sub === "running" ? "disable" : "enable")
|
||||
}
|
||||
busy={
|
||||
busy === `${s.unit}:enable` || busy === `${s.unit}:disable`
|
||||
}
|
||||
title={t("hostMetrics.managers.enableDisable")}
|
||||
>
|
||||
<Power className="size-3" />
|
||||
</ActionBtn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionBtn({
|
||||
onClick,
|
||||
busy,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
title={title}
|
||||
className="flex size-6 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { MemoryStick, Timer, HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarSeries, StatRow } from "@/components/charts";
|
||||
import { useManagerData } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
interface MemProc {
|
||||
pid: number;
|
||||
user: string;
|
||||
mem: number;
|
||||
rss: number;
|
||||
command: string;
|
||||
}
|
||||
interface TimerRow {
|
||||
unit: string;
|
||||
activates: string;
|
||||
next: string;
|
||||
}
|
||||
interface MountUsage {
|
||||
filesystem: string;
|
||||
usePct: number;
|
||||
usedKb: number;
|
||||
sizeKb: number;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
function fmtGiB(kb: number): string {
|
||||
return `${(kb / 1024 / 1024).toFixed(1)}G`;
|
||||
}
|
||||
|
||||
export function TopMemoryCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
processes: MemProc[];
|
||||
}>(hostId, "top-memory");
|
||||
const [query, setQuery] = useState("");
|
||||
const all = useMemo(() => data?.processes ?? [], [data?.processes]);
|
||||
const procs = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(p) =>
|
||||
!q ||
|
||||
p.command.toLowerCase().includes(q) ||
|
||||
String(p.pid).includes(q) ||
|
||||
p.user.toLowerCase().includes(q),
|
||||
);
|
||||
}, [all, query]);
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.topMemory")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && all.length === 0}
|
||||
>
|
||||
{all.length > 5 && (
|
||||
<ManagerSearch value={query} onChange={setQuery} count={procs.length} />
|
||||
)}
|
||||
<BarSeries
|
||||
items={procs.map((p) => ({
|
||||
label: `${p.command} (${p.pid})`,
|
||||
value: p.mem,
|
||||
valueLabel: `${p.mem.toFixed(1)}%`,
|
||||
}))}
|
||||
max={Math.max(1, ...procs.map((p) => p.mem))}
|
||||
/>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemdTimersCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
timers: TimerRow[];
|
||||
}>(hostId, "timers");
|
||||
const [query, setQuery] = useState("");
|
||||
const all = useMemo(() => data?.timers ?? [], [data?.timers]);
|
||||
const timers = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(tm) =>
|
||||
!q ||
|
||||
tm.unit.toLowerCase().includes(q) ||
|
||||
tm.activates.toLowerCase().includes(q),
|
||||
);
|
||||
}, [all, query]);
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.systemdTimers")}
|
||||
icon={<Timer className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && all.length === 0}
|
||||
>
|
||||
{all.length > 5 && (
|
||||
<ManagerSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
count={timers.length}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{timers.map((tm) => (
|
||||
<StatRow
|
||||
key={tm.unit}
|
||||
label={tm.unit.replace(/\.timer$/, "")}
|
||||
value={tm.activates}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiskBreakdownCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<{
|
||||
mounts: MountUsage[];
|
||||
}>(hostId, "disk-breakdown");
|
||||
const [query, setQuery] = useState("");
|
||||
const all = useMemo(() => data?.mounts ?? [], [data?.mounts]);
|
||||
const mounts = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(m) =>
|
||||
!q ||
|
||||
m.mount.toLowerCase().includes(q) ||
|
||||
m.filesystem.toLowerCase().includes(q),
|
||||
);
|
||||
}, [all, query]);
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.diskBreakdown")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && all.length === 0}
|
||||
>
|
||||
{all.length > 5 && (
|
||||
<ManagerSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
count={mounts.length}
|
||||
/>
|
||||
)}
|
||||
<BarSeries
|
||||
items={mounts.map((m) => ({
|
||||
label: `${m.mount} (${fmtGiB(m.usedKb)}/${fmtGiB(m.sizeKb)})`,
|
||||
value: m.usePct,
|
||||
valueLabel: `${m.usePct}%`,
|
||||
}))}
|
||||
max={100}
|
||||
/>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import {
|
||||
useManagerData,
|
||||
useManagerAction,
|
||||
extractError,
|
||||
} from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface CertInfo {
|
||||
client: string;
|
||||
name: string;
|
||||
domains: string[];
|
||||
expiry: string | null;
|
||||
}
|
||||
interface SslData {
|
||||
clients: { certbot: boolean; acmeSh: boolean };
|
||||
certs: CertInfo[];
|
||||
}
|
||||
|
||||
type Challenge = "http-standalone" | "http-webroot" | "dns";
|
||||
|
||||
/** Days until the given expiry string, or null if unparseable. */
|
||||
function daysUntil(expiry: string | null): number | null {
|
||||
if (!expiry) return null;
|
||||
const ts = Date.parse(expiry);
|
||||
if (Number.isNaN(ts)) return null;
|
||||
return Math.round((ts - Date.now()) / 86400000);
|
||||
}
|
||||
|
||||
function ExpiryBadge({ expiry }: { expiry: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
const days = daysUntil(expiry);
|
||||
if (days == null)
|
||||
return <span className="text-muted-foreground">{expiry ?? "—"}</span>;
|
||||
const tone =
|
||||
days < 0
|
||||
? "text-destructive"
|
||||
: days < 14
|
||||
? "text-yellow-500"
|
||||
: "text-accent-brand";
|
||||
return (
|
||||
<span className={tone} title={expiry ?? undefined}>
|
||||
{days < 0
|
||||
? t("hostMetrics.managers.sslExpired")
|
||||
: t("hostMetrics.managers.sslInDays", { days })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SslManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<SslData>(
|
||||
hostId,
|
||||
"ssl",
|
||||
);
|
||||
const { busy, run } = useManagerAction(hostId);
|
||||
const [showIssue, setShowIssue] = useState(false);
|
||||
const [domains, setDomains] = useState("");
|
||||
const [challenge, setChallenge] = useState<Challenge>("http-standalone");
|
||||
const [webroot, setWebroot] = useState("");
|
||||
const [dnsProvider, setDnsProvider] = useState("");
|
||||
const [issuing, setIssuing] = useState(false);
|
||||
|
||||
const clients = data?.clients;
|
||||
const activeClient: "certbot" | "acme.sh" | null = clients?.certbot
|
||||
? "certbot"
|
||||
: clients?.acmeSh
|
||||
? "acme.sh"
|
||||
: null;
|
||||
|
||||
const certs = useMemo(() => data?.certs ?? [], [data?.certs]);
|
||||
|
||||
const renew = (dryRun: boolean) => {
|
||||
if (!activeClient) return;
|
||||
run(
|
||||
"ssl",
|
||||
{ client: activeClient, dryRun },
|
||||
{
|
||||
action: "renew",
|
||||
toastId: "ssl-op",
|
||||
loadingMsg: t("hostMetrics.managers.working"),
|
||||
successMsg: t("hostMetrics.managers.actionDone", { name: "renew" }),
|
||||
failMsg: t("hostMetrics.managers.actionFailed"),
|
||||
onDone: refresh,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const revoke = (cert: CertInfo) => {
|
||||
if (cert.client !== "certbot" && cert.client !== "acme.sh") return;
|
||||
if (
|
||||
!window.confirm(
|
||||
t("hostMetrics.managers.sslRevokeConfirm", { name: cert.name }),
|
||||
)
|
||||
)
|
||||
return;
|
||||
run(
|
||||
"ssl",
|
||||
{ client: cert.client, name: cert.name },
|
||||
{
|
||||
action: "revoke",
|
||||
toastId: "ssl-op",
|
||||
loadingMsg: t("hostMetrics.managers.working"),
|
||||
successMsg: t("hostMetrics.managers.sslRevoked"),
|
||||
failMsg: t("hostMetrics.managers.actionFailed"),
|
||||
onDone: refresh,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const issue = async () => {
|
||||
if (hostId == null || !activeClient) return;
|
||||
const list = domains
|
||||
.split(/[\s,]+/)
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length === 0) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("hostMetrics.managers.sslNeedDomain"));
|
||||
return;
|
||||
}
|
||||
setIssuing(true);
|
||||
const { toast } = await import("sonner");
|
||||
toast.loading(t("hostMetrics.managers.working"), { id: "ssl-issue" });
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"ssl",
|
||||
{
|
||||
client: activeClient,
|
||||
domains: list,
|
||||
challenge,
|
||||
webroot: challenge === "http-webroot" ? webroot : undefined,
|
||||
dnsProvider: challenge === "dns" ? dnsProvider : undefined,
|
||||
},
|
||||
"issue",
|
||||
);
|
||||
toast[res.success ? "success" : "error"](
|
||||
res.success
|
||||
? t("hostMetrics.managers.sslIssued")
|
||||
: t("hostMetrics.managers.actionFailed"),
|
||||
{ id: "ssl-issue", description: res.output?.slice(-200) },
|
||||
);
|
||||
if (res.success) {
|
||||
setShowIssue(false);
|
||||
setDomains("");
|
||||
refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message, { id: "ssl-issue" });
|
||||
} finally {
|
||||
setIssuing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.ssl")}
|
||||
icon={<ShieldCheck className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!loading && certs.length === 0 && !activeClient}
|
||||
emptyMessage={t("hostMetrics.managers.noAcmeClient")}
|
||||
headerExtra={
|
||||
activeClient ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => renew(true)}
|
||||
>
|
||||
{t("hostMetrics.managers.dryRun")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy}
|
||||
onClick={() => renew(false)}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
{t("hostMetrics.managers.renew")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("hostMetrics.managers.clients")}:</span>
|
||||
<span className={clients?.certbot ? "text-accent-brand" : ""}>
|
||||
certbot
|
||||
</span>
|
||||
<span className={clients?.acmeSh ? "text-accent-brand" : ""}>
|
||||
acme.sh
|
||||
</span>
|
||||
</div>
|
||||
{activeClient && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setShowIssue((v) => !v)}
|
||||
>
|
||||
{showIssue ? <X className="size-3" /> : <Plus className="size-3" />}
|
||||
{t("hostMetrics.managers.sslIssueCert")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showIssue && activeClient && (
|
||||
<div className="mb-3 flex flex-col gap-2 border border-dashed border-border p-2">
|
||||
<input
|
||||
value={domains}
|
||||
onChange={(e) => setDomains(e.target.value)}
|
||||
placeholder={t("hostMetrics.managers.sslDomainsPlaceholder")}
|
||||
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select2
|
||||
value={challenge}
|
||||
onChange={(e) => setChallenge(e.target.value as Challenge)}
|
||||
className="h-7 flex-1 border border-border bg-background px-1 text-xs"
|
||||
>
|
||||
<option value="http-standalone">
|
||||
{t("hostMetrics.managers.sslHttpStandalone")}
|
||||
</option>
|
||||
<option value="http-webroot">
|
||||
{t("hostMetrics.managers.sslHttpWebroot")}
|
||||
</option>
|
||||
<option value="dns">{t("hostMetrics.managers.sslDns")}</option>
|
||||
</Select2>
|
||||
</div>
|
||||
{challenge === "http-webroot" && (
|
||||
<input
|
||||
value={webroot}
|
||||
onChange={(e) => setWebroot(e.target.value)}
|
||||
placeholder="/var/www/html"
|
||||
className="h-7 w-full border border-border bg-background px-2 font-mono text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
{challenge === "dns" && (
|
||||
<input
|
||||
value={dnsProvider}
|
||||
onChange={(e) => setDnsProvider(e.target.value)}
|
||||
placeholder={t("hostMetrics.managers.sslDnsProvider")}
|
||||
className="h-7 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={issuing}
|
||||
onClick={issue}
|
||||
className="self-start"
|
||||
>
|
||||
{t("hostMetrics.managers.sslIssueCert")}
|
||||
</Button>
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{t("hostMetrics.managers.sslIssueHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{certs.map((c) => (
|
||||
<div
|
||||
key={`${c.client}-${c.name}`}
|
||||
className="flex items-center justify-between gap-2 py-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
className="truncate font-semibold"
|
||||
title={c.domains.join(", ")}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="font-mono text-[11px]">
|
||||
<ExpiryBadge expiry={c.expiry} />
|
||||
</span>
|
||||
{(c.client === "certbot" || c.client === "acme.sh") && (
|
||||
<button
|
||||
onClick={() => revoke(c)}
|
||||
disabled={busy}
|
||||
title={t("hostMetrics.managers.sslRevoke")}
|
||||
className="text-muted-foreground transition-colors hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import { Power } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useManagerData, useManagerAction } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
interface TailscalePeer {
|
||||
hostname: string;
|
||||
tailscaleIPs: string[];
|
||||
online: boolean;
|
||||
isExitNode: boolean;
|
||||
}
|
||||
|
||||
interface TailscaleData {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
tailscaleIPs: string[];
|
||||
hostname: string | null;
|
||||
peers: TailscalePeer[];
|
||||
exitNodeInUse: boolean;
|
||||
}
|
||||
|
||||
export function TailscaleManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<TailscaleData>(
|
||||
hostId,
|
||||
"tailscale",
|
||||
);
|
||||
const { busy, run } = useManagerAction(hostId);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const handleToggle = async () => {
|
||||
if (!data) return;
|
||||
const action = data.running ? "down" : "up";
|
||||
await run(
|
||||
"tailscale",
|
||||
{ action },
|
||||
{
|
||||
action: "action",
|
||||
loadingMsg: data.running
|
||||
? t("hostMetrics.managers.tsDisabling")
|
||||
: t("hostMetrics.managers.tsEnabling"),
|
||||
successMsg: data.running
|
||||
? t("hostMetrics.managers.tsDisabled")
|
||||
: t("hostMetrics.managers.tsEnabled"),
|
||||
onDone: refresh,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const filteredPeers = (data?.peers ?? []).filter(
|
||||
(p) =>
|
||||
!query ||
|
||||
p.hostname.toLowerCase().includes(query.toLowerCase()) ||
|
||||
p.tailscaleIPs.some((ip) => ip.includes(query)),
|
||||
);
|
||||
|
||||
const isNotInstalled = data && !data.installed;
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.tailscale")}
|
||||
icon={<Power className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={!!isNotInstalled}
|
||||
emptyMessage={t("hostMetrics.managers.tsNotInstalled")}
|
||||
>
|
||||
{data && (
|
||||
<div className="flex flex-col">
|
||||
{/* Status + toggle row */}
|
||||
<div className="flex items-center justify-between border-b border-border/50 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${data.running ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold">
|
||||
{data.running
|
||||
? t("hostMetrics.managers.tsRunning")
|
||||
: t("hostMetrics.managers.tsStopped")}
|
||||
</span>
|
||||
{data.hostname && (
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{data.hostname}
|
||||
</span>
|
||||
)}
|
||||
{data.tailscaleIPs.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="truncate font-mono text-[10px] text-muted-foreground/60"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={busy || !data.installed}
|
||||
title={
|
||||
data.running
|
||||
? t("hostMetrics.managers.tsDisable")
|
||||
: t("hostMetrics.managers.tsEnable")
|
||||
}
|
||||
className={`flex size-6 items-center justify-center transition-colors disabled:opacity-40 ${
|
||||
data.running
|
||||
? "text-accent-brand hover:bg-muted hover:text-destructive"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-accent-brand"
|
||||
}`}
|
||||
>
|
||||
<Power className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exit node badge */}
|
||||
{data.exitNodeInUse && (
|
||||
<div className="border-b border-border/50 px-0 py-1.5">
|
||||
<span className="text-[10px] text-blue-500">
|
||||
{t("hostMetrics.managers.tsExitNodeActive")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Peers */}
|
||||
{(data.peers?.length ?? 0) > 5 && (
|
||||
<ManagerSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
count={filteredPeers.length}
|
||||
/>
|
||||
)}
|
||||
{filteredPeers.map((peer) => (
|
||||
<div
|
||||
key={peer.hostname}
|
||||
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${peer.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-semibold">
|
||||
{peer.hostname}
|
||||
</span>
|
||||
{peer.tailscaleIPs.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{peer.isExitNode && (
|
||||
<span className="shrink-0 text-[10px] text-blue-500">
|
||||
{t("hostMetrics.managers.tsExitNode")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Users, Plus, Trash2, ShieldCheck, Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { managerPost } from "@/main-axios";
|
||||
import { useManagerData, extractError } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
import { ManagerSearch } from "./ManagerToolbar";
|
||||
|
||||
interface SystemUser {
|
||||
name: string;
|
||||
uid: number;
|
||||
shell: string;
|
||||
}
|
||||
interface UsersData {
|
||||
users: SystemUser[];
|
||||
sudoers: string[];
|
||||
}
|
||||
|
||||
export function UserManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<UsersData>(
|
||||
hostId,
|
||||
"users",
|
||||
);
|
||||
const [newUser, setNewUser] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const sudoers = new Set(data?.sudoers ?? []);
|
||||
|
||||
const users = useMemo(
|
||||
() =>
|
||||
(data?.users ?? []).filter(
|
||||
(u) => !filter || u.name.toLowerCase().includes(filter.toLowerCase()),
|
||||
),
|
||||
[data?.users, filter],
|
||||
);
|
||||
|
||||
const action = async (act: string, username: string, group?: string) => {
|
||||
if (hostId == null) return;
|
||||
setBusy(username);
|
||||
try {
|
||||
const res = await managerPost<{ success: boolean; output: string }>(
|
||||
hostId,
|
||||
"users",
|
||||
{ action: act, username, group },
|
||||
"action",
|
||||
);
|
||||
if (res.success) {
|
||||
toast.success(t("hostMetrics.managers.actionDone", { name: username }));
|
||||
if (act === "create") setNewUser("");
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(res.output || t("hostMetrics.managers.actionFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(extractError(e).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.users")}
|
||||
icon={<Users className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<input
|
||||
value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)}
|
||||
placeholder={t("hostMetrics.managers.newUsername")}
|
||||
className="h-7 flex-1 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={!newUser || busy === newUser}
|
||||
onClick={() => action("create", newUser)}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("hostMetrics.managers.addUser")}
|
||||
</Button>
|
||||
</div>
|
||||
<ManagerSearch value={filter} onChange={setFilter} count={users.length} />
|
||||
<div className="flex flex-col">
|
||||
{users.map((u) => {
|
||||
const isSudoer = sudoers.has(u.name);
|
||||
return (
|
||||
<div
|
||||
key={u.name}
|
||||
className="flex items-center justify-between gap-2 border-b border-border/50 py-1.5 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-xs font-semibold">{u.name}</span>
|
||||
{isSudoer && (
|
||||
<ShieldCheck className="size-3 shrink-0 text-accent-brand" />
|
||||
)}
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">
|
||||
{u.uid}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
action(
|
||||
isSudoer ? "removeFromGroup" : "addToGroup",
|
||||
u.name,
|
||||
"sudo",
|
||||
)
|
||||
}
|
||||
disabled={busy === u.name}
|
||||
title={
|
||||
isSudoer
|
||||
? t("hostMetrics.managers.revokeSudo")
|
||||
: t("hostMetrics.managers.grantSudo")
|
||||
}
|
||||
className={`disabled:opacity-40 ${
|
||||
isSudoer
|
||||
? "text-accent-brand hover:text-muted-foreground"
|
||||
: "text-muted-foreground hover:text-accent-brand"
|
||||
}`}
|
||||
>
|
||||
<Shield className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action("delete", u.name)}
|
||||
disabled={busy === u.name}
|
||||
title={t("hostMetrics.managers.deleteUser")}
|
||||
className="text-muted-foreground hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Network,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useManagerData, useManagerAction } from "./useManagerData";
|
||||
import { ManagerCardShell } from "./ManagerCardShell";
|
||||
|
||||
interface WireGuardPeer {
|
||||
publicKey: string;
|
||||
endpoint: string | null;
|
||||
allowedIPs: string[];
|
||||
latestHandshake: number | null;
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
}
|
||||
|
||||
interface WireGuardInterface {
|
||||
name: string;
|
||||
publicKey: string | null;
|
||||
listenPort: number | null;
|
||||
up: boolean;
|
||||
peers: WireGuardPeer[];
|
||||
}
|
||||
|
||||
interface WireGuardData {
|
||||
installed: boolean;
|
||||
interfaces: WireGuardInterface[];
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n >= 1024 * 1024 * 1024)
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(1)}GB`;
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(1)}KB`;
|
||||
return `${n}B`;
|
||||
}
|
||||
|
||||
function fmtHandshake(ts: number | null): string {
|
||||
if (!ts) return "";
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function truncKey(key: string): string {
|
||||
return key.length > 12 ? `${key.slice(0, 12)}...` : key;
|
||||
}
|
||||
|
||||
export function WireGuardManagerCard({ hostId }: { hostId: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { data, loading, error, refresh } = useManagerData<WireGuardData>(
|
||||
hostId,
|
||||
"wireguard",
|
||||
);
|
||||
const { busy, run } = useManagerAction(hostId);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleExpand = (name: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = async (iface: WireGuardInterface) => {
|
||||
const action = iface.up ? "down" : "up";
|
||||
await run(
|
||||
"wireguard",
|
||||
{ interface: iface.name, action },
|
||||
{
|
||||
action: "action",
|
||||
loadingMsg:
|
||||
action === "up"
|
||||
? t("hostMetrics.managers.wgBringingUp", { name: iface.name })
|
||||
: t("hostMetrics.managers.wgBringingDown", { name: iface.name }),
|
||||
successMsg:
|
||||
action === "up"
|
||||
? t("hostMetrics.managers.wgInterfaceUpDone", { name: iface.name })
|
||||
: t("hostMetrics.managers.wgInterfaceDownDone", {
|
||||
name: iface.name,
|
||||
}),
|
||||
onDone: refresh,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const isNotInstalled = data && !data.installed;
|
||||
const isEmpty = data?.installed && data.interfaces.length === 0;
|
||||
|
||||
return (
|
||||
<ManagerCardShell
|
||||
title={t("hostMetrics.managers.wireguard")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRefresh={refresh}
|
||||
empty={isNotInstalled || isEmpty}
|
||||
emptyMessage={
|
||||
isNotInstalled
|
||||
? t("hostMetrics.managers.wgNotInstalled")
|
||||
: t("hostMetrics.managers.wgNoInterfaces")
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{data?.interfaces.map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
className="border-b border-border/50 last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => toggleExpand(iface.name)}
|
||||
>
|
||||
{expanded.has(iface.name) ? (
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${iface.up ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{iface.up ? "up" : "down"}
|
||||
{iface.listenPort ? ` · :${iface.listenPort}` : ""}
|
||||
{iface.peers.length > 0 ? ` · ${iface.peers.length}p` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(iface)}
|
||||
disabled={busy}
|
||||
title={
|
||||
iface.up
|
||||
? t("hostMetrics.managers.wgInterfaceDown")
|
||||
: t("hostMetrics.managers.wgInterfaceUp")
|
||||
}
|
||||
className="flex size-6 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{iface.up ? (
|
||||
<ArrowDown className="size-3" />
|
||||
) : (
|
||||
<ArrowUp className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded.has(iface.name) && (
|
||||
<div className="mb-1 ml-5 flex flex-col border-l border-border/50">
|
||||
{iface.peers.length === 0 ? (
|
||||
<span className="px-3 py-1 text-[10px] text-muted-foreground/50">
|
||||
No peers
|
||||
</span>
|
||||
) : (
|
||||
iface.peers.map((peer) => (
|
||||
<div
|
||||
key={peer.publicKey}
|
||||
className="flex flex-col gap-0.5 border-b border-border/30 px-3 py-1.5 last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{truncKey(peer.publicKey)}
|
||||
</span>
|
||||
{peer.endpoint && (
|
||||
<span className="font-mono text-[10px] text-muted-foreground/60">
|
||||
{peer.endpoint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{peer.allowedIPs.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
{peer.allowedIPs.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/50">
|
||||
<span>
|
||||
{t("hostMetrics.managers.wgLastHandshake")}:{" "}
|
||||
{peer.latestHandshake
|
||||
? fmtHandshake(peer.latestHandshake)
|
||||
: t("hostMetrics.managers.wgHandshakeNever")}
|
||||
</span>
|
||||
<span>
|
||||
{fmtBytes(peer.rxBytes)} / {fmtBytes(peer.txBytes)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ManagerCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { managerGet, managerPost } from "@/main-axios";
|
||||
|
||||
interface ManagerError {
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
function extractError(err: unknown): ManagerError {
|
||||
const e = err as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return {
|
||||
message: e?.response?.data?.error || e?.message || "Request failed",
|
||||
code: e?.response?.data?.code,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a manager read resource on mount + manual refresh, with loading/error
|
||||
* state. `hostId` null disables fetching.
|
||||
*/
|
||||
export function useManagerData<T>(
|
||||
hostId: number | null,
|
||||
resource: string,
|
||||
params?: Record<string, string | number>,
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<ManagerError | null>(null);
|
||||
|
||||
const paramsKey = params ? JSON.stringify(params) : "";
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (hostId == null) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await managerGet<T>(hostId, resource, params);
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
setError(extractError(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostId, resource, paramsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { data, loading, error, refresh, setData };
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a manager POST action with consistent toast feedback (loading -> success
|
||||
* /error) and a busy flag. On success the optional `onDone` callback runs (e.g.
|
||||
* to refresh the card). Returns the result so callers can branch further.
|
||||
*/
|
||||
export function useManagerAction(hostId: number | null) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = useCallback(
|
||||
async <T extends ActionResult>(
|
||||
resource: string,
|
||||
body: unknown,
|
||||
opts: {
|
||||
action?: string;
|
||||
toastId?: string;
|
||||
loadingMsg?: string;
|
||||
successMsg?: string;
|
||||
failMsg?: string;
|
||||
onDone?: () => void;
|
||||
} = {},
|
||||
): Promise<T | null> => {
|
||||
if (hostId == null) return null;
|
||||
const id = opts.toastId ?? `${resource}-action`;
|
||||
setBusy(true);
|
||||
if (opts.loadingMsg) toast.loading(opts.loadingMsg, { id });
|
||||
try {
|
||||
const res = await managerPost<T>(hostId, resource, body, opts.action);
|
||||
if (res.success) {
|
||||
if (opts.successMsg)
|
||||
toast.success(opts.successMsg, {
|
||||
id,
|
||||
description: res.output?.slice(-200),
|
||||
});
|
||||
else toast.dismiss(id);
|
||||
opts.onDone?.();
|
||||
} else {
|
||||
toast.error(opts.failMsg ?? res.output ?? "Action failed", {
|
||||
id,
|
||||
description: res.output?.slice(-200),
|
||||
});
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
toast.error(extractError(err).message, { id });
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[hostId],
|
||||
);
|
||||
|
||||
return { busy, run };
|
||||
}
|
||||
|
||||
export { extractError };
|
||||
export type { ManagerError };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getHostMetricsLayout, saveHostMetricsLayout } from "@/main-axios";
|
||||
import type { HostMetricsLayout } from "@/types/host-metrics";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 800;
|
||||
|
||||
function lsKey(hostId: number) {
|
||||
return `hostMetricsLayout:${hostId}`;
|
||||
}
|
||||
|
||||
function readCache(hostId: number): HostMetricsLayout | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(lsKey(hostId));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.slots))
|
||||
return parsed as HostMetricsLayout;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeCache(hostId: number, layout: HostMetricsLayout) {
|
||||
try {
|
||||
localStorage.setItem(lsKey(hostId), JSON.stringify(layout));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-host Host Metrics layout, cached in localStorage for instant paint and
|
||||
* synced to the backend (so it follows the user across devices). Saves are
|
||||
* debounced. Returns null layout until the first load resolves.
|
||||
*/
|
||||
export function useHostMetricsPreferences(hostId: number | null) {
|
||||
const [layout, setLayout] = useState<HostMetricsLayout | null>(() =>
|
||||
hostId != null ? readCache(hostId) : null,
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hostId == null) return;
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
const cached = readCache(hostId);
|
||||
if (cached) setLayout(cached);
|
||||
|
||||
getHostMetricsLayout(hostId)
|
||||
.then((remote) => {
|
||||
if (cancelled || !remote) return;
|
||||
setLayout(remote);
|
||||
writeCache(hostId, remote);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep cache/default */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoaded(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hostId]);
|
||||
|
||||
const save = useCallback(
|
||||
(next: HostMetricsLayout) => {
|
||||
if (hostId == null) return;
|
||||
setLayout(next);
|
||||
writeCache(hostId, next);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
saveHostMetricsLayout(hostId, next).catch(() => {
|
||||
/* best-effort; cache already holds it */
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[hostId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { layout, setLayout: save, loaded };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Frontend half of the host-metrics plugin.
|
||||
*
|
||||
* Registers the Host Metrics tab through the existing extension seams
|
||||
* (registerRailItem / registerTabComponent), the same way ssh-terminal's and
|
||||
* docker's frontend do.
|
||||
*
|
||||
* HostEditorStatsTab itself stays in src/ui/sidebar/HostEditorStatsTab.tsx
|
||||
* rather than moving into this plugin: it takes the same {form, setField}
|
||||
* pair every other host editor tab does, and those types (HostEditorForm,
|
||||
* the setField signature) are core types HostEditor.tsx owns -- the same
|
||||
* reason docker leaves HostDockerTab in core.
|
||||
*
|
||||
* register() is called when the plugin is enabled, unregister() when it is
|
||||
* disabled. As of this change neither is actually invoked anywhere yet, the
|
||||
* same as ssh-terminal's and docker's frontend entries -- there is no
|
||||
* frontend plugin loader yet. This file follows the same shape so it is
|
||||
* ready the moment that loader exists.
|
||||
*/
|
||||
|
||||
export const id = "host-metrics";
|
||||
export const tabId = "host-metrics";
|
||||
|
||||
export async function register({
|
||||
registerRailItem,
|
||||
registerTabComponent,
|
||||
icons,
|
||||
}) {
|
||||
registerTabComponent(tabId, () =>
|
||||
import("./HostMetricsTab.tsx").then((m) => ({
|
||||
default: m.HostMetricsTab,
|
||||
})),
|
||||
);
|
||||
|
||||
registerRailItem({
|
||||
id: tabId,
|
||||
icon: icons.Activity,
|
||||
labelKey: "nav.hostMetrics",
|
||||
kind: "tab",
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregister({
|
||||
unregisterRailItem,
|
||||
unregisterTabComponent,
|
||||
}) {
|
||||
unregisterRailItem(tabId);
|
||||
unregisterTabComponent(tabId);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
|
||||
/**
|
||||
* Coarse fingerprint of a metrics poll. Adaptive polling backs off while it
|
||||
* stays the same, so it must move whenever something a card shows changes.
|
||||
*/
|
||||
export function metricsChangeKey(data: ServerMetrics): string {
|
||||
const bucket = (value: number | null | undefined) =>
|
||||
value == null ? null : Math.round(value / 5) * 5;
|
||||
return JSON.stringify({
|
||||
cpu: bucket(data.cpu.percent),
|
||||
memory: bucket(data.memory.percent),
|
||||
disk: bucket(data.disk.percent),
|
||||
running: data.processes?.running ?? null,
|
||||
ports: data.ports?.ports?.length ?? 0,
|
||||
firewall: data.firewall?.status ?? null,
|
||||
gpus:
|
||||
data.gpu?.gpus?.map((gpu) => [
|
||||
bucket(gpu.utilizationPercent),
|
||||
bucket(gpu.memoryPercent),
|
||||
]) ?? [],
|
||||
gpuProcesses: data.gpu?.processes?.length ?? 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TemperatureSensor } from "@/types/stats-widgets";
|
||||
|
||||
export function selectTemperatureSensor(
|
||||
sensors: TemperatureSensor[],
|
||||
preferredLabel: string,
|
||||
) {
|
||||
return sensors.find((sensor) => sensor.label === preferredLabel) ?? null;
|
||||
}
|
||||
|
||||
export function temperaturePreferenceKey(hostId: number) {
|
||||
return `termix-host-metrics:${hostId}:temperature-sensor`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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).",
|
||||
"author": { "name": "Termix" },
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Monitoring",
|
||||
"icon": "Activity",
|
||||
"engine": { "termix": ">=2.9.0", "api": "1" },
|
||||
"capabilities": {
|
||||
"backend": true,
|
||||
"frontend": true,
|
||||
"electron": true,
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
},
|
||||
"permissions": ["process:transport-owner"],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"titleKey": "nav.hostMetrics",
|
||||
"icon": "Activity",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"sidecars": []
|
||||
}
|
||||
Reference in new issue
Block a user