import { useEffect, useState } from "react"; import { Server } from "lucide-react"; import { useTranslation } from "react-i18next"; import { registerWidget } from "./WidgetRegistry"; import type { HostStatusConfig, HostMetricKey, WidgetComponentProps, } from "@/types/homepage-types"; import { GRID_SIZE } from "@/types/homepage-types"; import { getServerStatusById, getServerMetricsById, } from "@/api/host-metrics-status-api"; import { getSSHHosts } from "@/api/ssh-host-management-api"; import type { ServerMetrics } from "@/main-axios"; import { WidgetTitle } from "./WidgetTitle"; function getAccentColor(): string { return ( getComputedStyle(document.documentElement) .getPropertyValue("--accent-brand") .trim() || "#f59145" ); } const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"]; function migrateConfig(config: HostStatusConfig): HostMetricKey[] { if (config.shownMetrics?.length) return config.shownMetrics; if (config.showMetrics === false) return []; const metrics: HostMetricKey[] = ["cpu", "memory"]; if (config.showDisk) metrics.push("disk"); return metrics; } interface MetricBarProps { label: string; value: number | null; sublabel?: string; } function MetricBar({ label, value, sublabel }: MetricBarProps) { const pct = value ?? 0; const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : getAccentColor(); return (
{label} {value != null ? `${Math.round(pct)}%` : "N/A"} {sublabel && {sublabel}}
); } interface InfoRowProps { label: string; value: string | null | undefined; } function InfoRow({ label, value }: InfoRowProps) { if (!value) return null; return (
{label} {value}
); } function formatBytes(n: number): string { if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`; if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`; if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`; return `${n} B`; } function HostStatusWidget({ widget, config, }: WidgetComponentProps) { const { t } = useTranslation(); const shownMetrics = migrateConfig(config); const { hostId } = config; const [hostName, setHostName] = useState(null); const [online, setOnline] = useState(null); const [metrics, setMetrics] = useState(null); const needsMetrics = shownMetrics.length > 0; useEffect(() => { if (!hostId) return; getSSHHosts() .then((hosts) => { const host = hosts.find((h) => h.id === hostId); if (host) setHostName(host.name); }) .catch(() => {}); }, [hostId]); useEffect(() => { if (!hostId) return; let cancelled = false; const poll = async () => { try { const s = await getServerStatusById(hostId); if (!cancelled) setOnline(s.status === "online"); } catch { if (!cancelled) setOnline(false); } if (needsMetrics) { try { const m = await getServerMetricsById(hostId); if (!cancelled) setMetrics(m); } catch { if (!cancelled) setMetrics(null); } } }; poll(); let intervalId: ReturnType | null = null; const start = () => { if (intervalId !== null) return; // Align with global status cadence; metrics do not need sub-10s when hidden. intervalId = setInterval(() => { if (document.visibilityState === "hidden") return; void poll(); }, 30_000); }; const stop = () => { if (intervalId === null) return; clearInterval(intervalId); intervalId = null; }; const onVisibility = () => { if (document.visibilityState === "hidden") { stop(); return; } void poll(); start(); }; if (document.visibilityState !== "hidden") start(); document.addEventListener("visibilitychange", onVisibility); return () => { cancelled = true; stop(); document.removeEventListener("visibilitychange", onVisibility); }; }, [hostId, needsMetrics]); if (!hostId) { return (
{t("homepage.noHostSelected")}
); } const onlineColor = online === null ? "#6b7280" : online ? getAccentColor() : "#ef4444"; const onlineLabel = online === null ? t("common.unknown") : online ? t("common.online") : t("common.offline"); const networkIfaces = metrics?.network?.interfaces ?? []; const totalRx = networkIfaces.reduce((sum, iface) => { const raw = iface.rxBytes ?? iface.rx ?? null; return raw ? sum + parseFloat(raw) : sum; }, 0); const totalTx = networkIfaces.reduce((sum, iface) => { const raw = iface.txBytes ?? iface.tx ?? null; return raw ? sum + parseFloat(raw) : sum; }, 0); return (
} />
{hostName ?? `Host #${hostId}`} {onlineLabel}
{needsMetrics && (
{shownMetrics.includes("cpu") && ( )} {shownMetrics.includes("memory") && ( )} {shownMetrics.includes("disk") && ( )} {shownMetrics.includes("uptime") && ( )} {shownMetrics.includes("system") && metrics?.system && (
)} {shownMetrics.includes("network") && networkIfaces.length > 0 && (
{t("homepage.metricNetwork")}
{networkIfaces.slice(0, 3).map((iface) => (
{iface.name} {iface.ip || iface.state}
))} {(totalRx > 0 || totalTx > 0) && (
RX/TX {formatBytes(totalRx)} / {formatBytes(totalTx)}
)}
)} {shownMetrics.includes("processes") && metrics?.processes && (
{t("homepage.metricProcesses")} {metrics.processes.total ?? "?"}{" "} {t("homepage.metricProcessesTotal")} {metrics.processes.running != null ? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}` : ""}
{metrics.processes.top?.slice(0, 3).map((proc) => (
{proc.command} {proc.cpu}%
))}
)} {needsMetrics && !metrics && online && ( {t("homepage.metricsNotAvailable")} )}
)}
); } registerWidget({ id: "host_status", name: "Host Status", description: "Shows live status and metrics for an SSH host", category: "system", icon: , defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS }, defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 }, minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 }, component: HostStatusWidget, }); export { HostStatusWidget };