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

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

No files matched your search

@@ -0,0 +1,31 @@
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30;
export type MetricsChartMetric =
"cpu" | "memory" | "disk" | "net_rx" | "net_tx";
export type MetricsChartRange = "15m" | "1h" | "6h" | "24h";
export interface MetricsChartConfig {
hostId: number;
metric: MetricsChartMetric;
range: MetricsChartRange;
showCurrentValue: boolean;
}
/** What the homepage hands a widget; only the fields this plugin reads. */
export interface WidgetComponentProps<C> {
widget: { title?: string | null };
config: C;
}
export interface WidgetEditFormProps<C> {
config: C;
onChange: (config: C) => void;
}
export type WidgetDefinition<C> = Omit<
HomepageWidgetContribution<C>,
"defaultConfig"
> & { defaultConfig: C };
@@ -0,0 +1,145 @@
import type { WidgetType } from "./stats-widgets.js";
/**
* Host Metrics layout model. Shared by the frontend tab and the backend
* preferences endpoint. Metric card ids reuse the existing `WidgetType` values
* verbatim (so `enabledWidgets` stays valid for the mobile app); manager card
* ids are new and never enter the `enabledWidgets` host setting.
*/
export type HostMetricCardId = WidgetType;
export type HostMetricManagerId =
| "service_manager"
| "process_inspector"
| "log_viewer"
| "cron_manager"
| "package_manager"
| "ssl_manager"
| "firewall_manager"
| "user_manager"
| "health_check"
| "disk_breakdown"
| "systemd_timers"
| "top_memory"
| "wireguard_manager"
| "tailscale_manager";
export type HostMetricsCardId = HostMetricCardId | HostMetricManagerId;
export type HostMetricsColSpan = 1 | 2 | 3;
export interface HostMetricsSlot {
id: HostMetricsCardId;
order: number;
colSpan: HostMetricsColSpan;
height: number | null;
}
export interface HostMetricsLayout {
slots: HostMetricsSlot[];
columns: number;
}
/** Canonical order of metric cards (also the canonical `enabledWidgets` order). */
export const METRIC_CARD_IDS: HostMetricCardId[] = [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
"gpu",
];
export const MANAGER_CARD_IDS: HostMetricManagerId[] = [
"service_manager",
"process_inspector",
"log_viewer",
"cron_manager",
"package_manager",
"ssl_manager",
"firewall_manager",
"user_manager",
"health_check",
"disk_breakdown",
"systemd_timers",
"top_memory",
"wireguard_manager",
"tailscale_manager",
];
export function isMetricCardId(id: string): id is HostMetricCardId {
return (METRIC_CARD_IDS as string[]).includes(id);
}
export function isManagerCardId(id: string): id is HostMetricManagerId {
return (MANAGER_CARD_IDS as string[]).includes(id);
}
/**
* Derive the flat `enabledWidgets` array (canonical order, deduped, metric ids
* only) from a set of layout slots. This is the single source of truth keeping
* the `enabledWidgets` host setting valid for the mobile app.
*/
export function deriveEnabledWidgets(
slots: Array<{ id: string }>,
): WidgetType[] {
const present = new Set(slots.map((s) => s.id));
return METRIC_CARD_IDS.filter((id) => present.has(id));
}
const DEFAULT_COLSPAN: Partial<Record<HostMetricsCardId, HostMetricsColSpan>> =
{
cpu: 1,
memory: 1,
disk: 1,
uptime: 1,
system: 1,
network: 1,
temperature: 1,
processes: 2,
ports: 2,
firewall: 2,
login_stats: 2,
gpu: 2,
};
// All cards default to content/auto height (null); the masonry packs them
// tetris-style and scrollable cards cap their own body height. Users can still
// pin an explicit height by dragging a card's resize handle.
const DEFAULT_HEIGHT: Partial<Record<HostMetricsCardId, number | null>> = {};
/**
* Build a default layout from a list of enabled metric-card ids (e.g. the host's
* current the `enabledWidgets` host setting), preserving canonical order. Manager
* cards are not added by default.
*/
export function defaultLayoutFromWidgets(
enabledWidgets: string[],
columns = 3,
): HostMetricsLayout {
const enabled = new Set(enabledWidgets);
const ids = METRIC_CARD_IDS.filter((id) => enabled.has(id));
const slots: HostMetricsSlot[] = ids.map((id, i) => ({
id,
order: i,
colSpan: DEFAULT_COLSPAN[id] ?? 1,
height: DEFAULT_HEIGHT[id] ?? null,
}));
return { slots, columns };
}
export function defaultColSpanFor(id: HostMetricsCardId): HostMetricsColSpan {
return DEFAULT_COLSPAN[id] ?? (isManagerCardId(id) ? 2 : 1);
}
export function defaultHeightFor(id: HostMetricsCardId): number | null {
if (id in DEFAULT_HEIGHT) return DEFAULT_HEIGHT[id] ?? null;
return null;
}
+136
View File
@@ -0,0 +1,136 @@
import type { GpuMetrics } from "./stats-widgets.js";
/** What GET /metrics/:id answers. */
export interface CpuMetrics {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}
export interface MemoryMetrics {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}
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;
}
interface DiskMetrics {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman?: string | null;
mount?: string | null;
filesystems?: DiskFilesystem[];
}
export interface NetworkInterface {
name: string;
ip: string;
state: string;
rx?: string | null;
tx?: string | null;
rxBytes?: string | null;
txBytes?: string | null;
rxRateBps?: number | null;
txRateBps?: number | null;
}
export interface ProcessInfo {
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface ListeningPort {
protocol: "tcp" | "udp";
localAddress: string;
localPort: number;
state?: string;
pid?: number;
process?: string;
}
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export type ServerMetrics = {
cpu: CpuMetrics;
memory: MemoryMetrics;
disk: DiskMetrics;
network?: { interfaces?: NetworkInterface[] };
uptime?: { seconds?: number | null; formatted?: string | null };
system?: {
hostname?: string | null;
os?: string | null;
kernel?: string | null;
arch?: string | null;
};
processes?: {
total?: number | null;
running?: number | null;
top?: ProcessInfo[];
};
login_stats?: {
recentLogins?: LoginRecord[];
failedLogins?: LoginRecord[];
totalLogins?: number;
uniqueIPs?: number;
};
ports?: {
source?: "ss" | "netstat" | "none";
ports?: ListeningPort[];
};
firewall?: {
type?: "iptables" | "nftables" | "none";
status?: "active" | "inactive" | "unknown";
chains?: FirewallChain[];
};
temperature?: {
source?: "sysfs" | "sensors" | "none";
highestCelsius?: number | null;
sensors?: Array<{
label: string;
celsius: number;
}>;
};
gpu?: GpuMetrics;
lastChecked: string;
};
@@ -0,0 +1,161 @@
export type WidgetType =
| "cpu"
| "memory"
| "disk"
| "network"
| "uptime"
| "processes"
| "system"
| "login_stats"
| "ports"
| "firewall"
| "temperature"
| "gpu";
export interface ListeningPort {
protocol: "tcp" | "udp";
localAddress: string;
localPort: number;
state?: string;
pid?: number;
process?: string;
}
export interface PortsMetrics {
source: "ss" | "netstat" | "none";
ports: ListeningPort[];
}
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export interface FirewallMetrics {
type: "iptables" | "nftables" | "none";
status: "active" | "inactive" | "unknown";
chains: FirewallChain[];
}
export interface TemperatureSensor {
label: string;
celsius: number;
}
export interface TemperatureMetrics {
source: "sysfs" | "sensors" | "none";
highestCelsius: number | null;
sensors: TemperatureSensor[];
}
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 {
gpuIndex: number | null;
pid: number;
name: string;
memoryUsedMiB: number | null;
}
export interface GpuMetrics {
source: "nvidia-smi" | "none";
gpus: GpuDevice[];
processes: GpuProcess[];
}
/** This plugin's host settings, as core stores them in plugin_settings. */
export interface HostMetricsSettings {
metricsEnabled: boolean;
/** Seconds between samples; null follows the admin setting. */
metricsInterval: number | null;
enabledWidgets: WidgetType[];
/** Filesystem mount points to leave out of the disk widget. */
excludedMounts: string[];
/** Extra paths to monitor, including paths inside bind-mounted containers. */
monitoredMounts: Array<{ path: string; label?: string }>;
}
export const DEFAULT_ENABLED_WIDGETS: WidgetType[] = [
"cpu",
"memory",
"disk",
"network",
"uptime",
"system",
"login_stats",
"processes",
"ports",
"firewall",
"temperature",
];
export const DEFAULT_HOST_METRICS_SETTINGS: HostMetricsSettings = {
metricsEnabled: true,
metricsInterval: null,
enabledWidgets: DEFAULT_ENABLED_WIDGETS,
excludedMounts: [],
monitoredMounts: [],
};
/** Host settings with defaults filled in and bad values dropped. */
export function readHostMetricsSettings(
values: Record<string, unknown> | null | undefined,
): HostMetricsSettings {
const bag = values ?? {};
const interval = Number(bag.metricsInterval);
return {
metricsEnabled: bag.metricsEnabled !== false,
metricsInterval:
bag.metricsInterval != null &&
Number.isInteger(interval) &&
interval >= 5 &&
interval <= 3600
? interval
: null,
enabledWidgets: Array.isArray(bag.enabledWidgets)
? (bag.enabledWidgets.filter(
(widget) => typeof widget === "string",
) as WidgetType[])
: DEFAULT_ENABLED_WIDGETS,
excludedMounts: Array.isArray(bag.excludedMounts)
? bag.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
)
: [],
monitoredMounts: Array.isArray(bag.monitoredMounts)
? (bag.monitoredMounts as unknown[]).filter(
(mount): mount is { path: string; label?: string } =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
)
: [],
};
}