feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

@@ -0,0 +1,402 @@
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Activity,
HardDrive,
LayoutDashboard,
Plus,
Server,
Trash2,
Zap,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
import { Select2 } from "@/components/select2";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostStatsTab({
form,
setField,
snippets,
}: {
form: HostEditorForm;
setField: SetHostField;
snippets: { id: number; name: string }[];
}) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const excludedMounts = form.statsConfig.excludedMounts ?? [];
const monitoredMounts = form.statsConfig.monitoredMounts ?? [];
const addExcludedMount = () => {
const value = newMount.trim();
if (!value || excludedMounts.includes(value)) {
setNewMount("");
return;
}
setField("statsConfig", {
...form.statsConfig,
excludedMounts: [...excludedMounts, value],
});
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
],
});
setNewMonitoredPath("");
setNewMonitoredLabel("");
};
return (
<>
<SectionCard
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.metricsCollectionLabel")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableMetricsLabel")}
description={t("hosts.enableMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.metricsEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
metricsEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.metricsEnabled &&
!form.statsConfig.useGlobalMetricsInterval && (
<SettingRow
label={t("hosts.metricsIntervalS")}
description={t("hosts.metricsIntervalDesc2")}
>
<Input
type="number"
value={form.statsConfig.metricsInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
metricsInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.monitoredMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.monitoredMountsDesc")}
</p>
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountPathPlaceholder")}
value={newMonitoredPath}
onChange={(e) => setNewMonitoredPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountLabelPlaceholder")}
value={newMonitoredLabel}
onChange={(e) => setNewMonitoredLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={addMonitoredMount}
>
<Plus className="mr-1 size-3" /> {t("hosts.addMonitoredMount")}
</Button>
</div>
{monitoredMounts.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-2 border border-border bg-muted/20 p-2 group"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{entry.path}
</span>
{entry.label && (
<span className="truncate text-xs text-muted-foreground">
{entry.label}
</span>
)}
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.excludedMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.excludedMountsDesc")}
</p>
<div className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.excludedMountsPlaceholder")}
value={newMount}
onChange={(e) => setNewMount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addExcludedMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={addExcludedMount}
>
<Plus className="size-3 mr-1" /> {t("hosts.addExcludedMount")}
</Button>
</div>
{excludedMounts.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<HardDrive className="size-6" />
<span className="text-xs">{t("hosts.excludedMountsEmpty")}</span>
</div>
)}
{excludedMounts.map((mount, i) => (
<div
key={`${mount}-${i}`}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<span className="text-xs flex-1 font-mono truncate">{mount}</span>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.widgetsMovedToHostMetrics")}
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.quickActionsLabel")}
icon={<Zap className="size-3.5" />}
action={
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("quickActions", [
...form.quickActions,
{ name: "", snippetId: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addActionBtn")}
</Button>
}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.quickActionsToolbar")}
</p>
{form.quickActions.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<Zap className="size-6" />
<span className="text-xs">{t("hosts.noQuickActions")}</span>
</div>
)}
{form.quickActions.map((a, i) => (
<div
key={i}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.buttonLabel")}
value={a.name}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = { ...updated[i], name: e.target.value };
setField("quickActions", updated);
}}
/>
<Select2
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
value={a.snippetId}
onChange={(e) => {
const updated = [...form.quickActions];
updated[i] = {
...updated[i],
snippetId: e.target.value,
};
setField("quickActions", updated);
}}
>
<option value="">{t("hosts.selectSnippetPlaceholder")}</option>
{snippets.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</Select2>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField(
"quickActions",
form.quickActions.filter((_, idx) => idx !== i),
)
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
</>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { HostMetricsTab } from "./HostMetricsTab.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
@@ -23,7 +23,7 @@ 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 { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
type StatsConfig,
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type {
MetricsChartConfig,
MetricsChartMetric,
MetricsChartRange,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { Select2 } from "@/components/select2";
const METRICS: { id: MetricsChartMetric; label: string }[] = [
{ id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" },
{ id: "net_rx", label: "RX MB/s" },
{ id: "net_tx", label: "TX MB/s" },
];
const RANGES: { id: MetricsChartRange; label: string }[] = [
{ id: "15m", label: "15m" },
{ id: "1h", label: "1h" },
{ id: "6h", label: "6h" },
{ id: "24h", label: "24h" },
];
export function MetricsChartEditForm({
config,
onChange,
}: WidgetEditFormProps<MetricsChartConfig>) {
const { t } = useTranslation();
const [hosts, setHosts] = useState<{ id: number; name: string }[]>([]);
useEffect(() => {
getSSHHosts()
.then((h) => setHosts(h.map((x) => ({ id: x.id, name: x.name }))))
.catch(() => {});
}, []);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.host")}
</label>
<Select2
value={config.hostId}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs bg-background border border-border px-2 text-foreground"
>
<option value={0}>{t("homepage.selectHost")}</option>
{hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.chartMetric")}
</label>
<div className="flex flex-wrap gap-1">
{METRICS.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onChange({ ...config, metric: m.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.metric === m.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{m.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("homepage.metricRange")}
</label>
<div className="flex gap-1">
{RANGES.map((r) => (
<button
key={r.id}
type="button"
onClick={() => onChange({ ...config, range: r.id })}
className={`px-2 py-0.5 text-[10px] font-medium border transition-colors ${config.range === r.id ? "bg-accent-brand border-accent-brand text-white" : "border-border text-muted-foreground hover:border-accent-brand/50"}`}
>
{r.label}
</button>
))}
</div>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={config.showCurrentValue}
onChange={(e) =>
onChange({ ...config, showCurrentValue: e.target.checked })
}
className="accent-accent-brand"
/>
{t("homepage.showCurrentValue")}
</label>
</div>
);
}
@@ -0,0 +1,254 @@
import { useEffect, useState, useRef } from "react";
import { TrendingUp } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricsChartEditForm } from "./MetricsChartEditForm";
import type {
MetricsChartConfig,
MetricsChartMetric,
WidgetComponentProps,
WidgetTypeDefinition,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getMetricsHistory, type MetricsHistoryRow } from "./host-metrics-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
import { runVisibleInterval } from "@/features/homepage/use-visible-interval";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function getMetricValue(
row: MetricsHistoryRow,
metric: MetricsChartMetric,
): number | null {
switch (metric) {
case "cpu":
return row.cpu_percent;
case "memory":
return row.mem_percent;
case "disk":
return row.disk_percent;
case "net_rx":
return row.net_rx_bytes != null ? row.net_rx_bytes / 1_000_000 : null;
case "net_tx":
return row.net_tx_bytes != null ? row.net_tx_bytes / 1_000_000 : null;
}
}
function getMetricLabel(
metric: MetricsChartMetric,
t: (k: string) => string,
): string {
switch (metric) {
case "cpu":
return t("homepage.metricCpu");
case "memory":
return t("homepage.metricMemory");
case "disk":
return t("homepage.metricDisk");
case "net_rx":
return "RX MB/s";
case "net_tx":
return "TX MB/s";
}
}
function getMetricUnit(metric: MetricsChartMetric): string {
if (metric === "net_rx" || metric === "net_tx") return " MB/s";
return "%";
}
interface SparklineProps {
values: number[];
width: number;
height: number;
color: string;
}
function Sparkline({ values, width, height, color }: SparklineProps) {
if (values.length < 2) return null;
const max = Math.max(...values, 1);
const min = 0;
const range = max - min;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * width;
const y = height - ((v - min) / range) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const polyPts = [`0,${height}`, ...pts, `${width},${height}`].join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className="overflow-visible"
>
<defs>
<linearGradient
id={`mg-${color.replace("#", "")}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
</defs>
<polygon points={polyPts} fill={`url(#mg-${color.replace("#", "")})`} />
<polyline
points={pts.join(" ")}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function MetricsChartWidget({
widget,
config,
}: WidgetComponentProps<MetricsChartConfig>) {
const { t } = useTranslation();
const { hostId, metric, range, showCurrentValue } = config;
const [hostName, setHostName] = useState<string | null>(null);
const [values, setValues] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 0, h: 0 });
useEffect(() => {
if (!hostId) return;
getSSHHosts()
.then((hosts) => {
const h = hosts.find((x) => x.id === hostId);
if (h) setHostName(h.name);
})
.catch(() => {});
}, [hostId]);
useEffect(() => {
if (!containerRef.current) return;
const obs = new ResizeObserver((entries) => {
const e = entries[0]?.contentRect;
if (e) setDims({ w: Math.floor(e.width), h: Math.floor(e.height) });
});
obs.observe(containerRef.current);
return () => obs.disconnect();
}, []);
const fetchData = () => {
if (!hostId) return;
getMetricsHistory(hostId, { range })
.then((res) => {
const vals = res.rows
.map((r) => getMetricValue(r, metric))
.filter((v): v is number => v != null);
setValues(vals);
setLoading(false);
})
.catch(() => setLoading(false));
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 60_000);
}, [hostId, metric, range]);
const accent = getAccentColor();
const current = values.length > 0 ? values[values.length - 1] : null;
const unit = getMetricUnit(metric);
const label = getMetricLabel(metric, t);
if (!hostId) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noHostSelected")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<TrendingUp size={11} />} />
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
<div className="flex items-center justify-between gap-1 shrink-0">
<div className="flex flex-col min-w-0">
<span className="text-[10px] font-semibold text-foreground truncate">
{hostName ?? `Host #${hostId}`}
</span>
<span className="text-[9px] text-muted-foreground">
{label} · {range}
</span>
</div>
{showCurrentValue && current != null && (
<span
className="text-sm font-bold tabular-nums shrink-0"
style={{ color: accent }}
>
{metric === "net_rx" || metric === "net_tx"
? current.toFixed(2)
: Math.round(current)}
{unit}
</span>
)}
</div>
<div ref={containerRef} className="flex-1 min-h-0 relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
{t("homepage.loading")}
</div>
) : values.length < 2 ? (
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-muted-foreground/60">
{t("homepage.noData")}
</div>
) : (
dims.w > 0 &&
dims.h > 0 && (
<Sparkline
values={values}
width={dims.w}
height={dims.h}
color={accent}
/>
)
)}
</div>
</div>
</div>
);
}
/** Registered by the plugin while it runs. */
export const metricsChartWidget: WidgetTypeDefinition<MetricsChartConfig> = {
id: "metrics_chart",
name: "Metrics Chart",
description: "Historical CPU, memory, disk, or network chart for a host",
category: "monitoring",
icon: <TrendingUp size={14} />,
defaultConfig: {
hostId: 0,
metric: "cpu",
range: "1h",
showCurrentValue: true,
},
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 4 },
component: MetricsChartWidget,
editFormComponent: MetricsChartEditForm,
};
export { MetricsChartWidget };
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
import { Button, Input } from "@termix/plugin-sdk/ui";
import {
getMetricsHistoryRetention,
saveMetricsHistoryRetention,
} from "./host-metrics-api";
/**
* How long metrics history is kept. Lives in the metrics backend's own
* setting, so it saves with its own button rather than the page's.
*/
export function MetricsRetentionSetting() {
const { t } = useTranslation();
const toast = useToast();
const [days, setDays] = useState("7");
useEffect(() => {
let cancelled = false;
getMetricsHistoryRetention()
.then((value) => {
if (!cancelled) setDays(String(value));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const save = async () => {
const value = Number.parseInt(days, 10);
if (Number.isNaN(value) || value < 1 || value > 90) {
toast.error(t("admin.metricsHistoryRetentionRange"));
return;
}
try {
await saveMetricsHistoryRetention(value);
toast.success(t("admin.monitoringSaved"));
} catch {
toast.error(t("admin.monitoringSaveFailed"));
}
};
return (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.metricsHistoryRetention")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={90}
value={days}
onChange={(event) => setDays(event.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">{t("admin.days")}</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={() => void save()}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.metricsHistoryRetentionRange")}
</span>
</div>
);
}
@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
export type HistoryTab = "live" | "1h" | "6h" | "24h" | "7d";
@@ -1,14 +1,11 @@
import { useState, useEffect } from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
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 { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function computeChartData(rows: MetricsHistoryRow[]) {
@@ -1,15 +1,12 @@
import { useState, useEffect, useMemo } from "react";
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
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 { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function DiskCard({
@@ -1,5 +1,5 @@
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DiskFilesystem } from "@/main-axios";
import { cn } from "@/lib/utils";
import {
@@ -1,5 +1,5 @@
import { ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
@@ -1,6 +1,6 @@
import { useId } from "react";
import { Gpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import type { GpuDevice, GpuProcess } from "@/types/stats-widgets";
import { MiniStat, RadialGauge, Sparkline, StatRow } from "@/components/charts";
@@ -1,5 +1,5 @@
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
@@ -1,14 +1,11 @@
import { useState, useEffect } from "react";
import { MemoryStick } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
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 { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
export function MemoryCard({
@@ -1,13 +1,10 @@
import { useState, useEffect } from "react";
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
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 { getMetricsHistory, type MetricsHistoryRow } from "../host-metrics-api";
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
function ifaceIcon(name: string) {
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Unplug, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics, ListeningPort } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
import { Select2 } from "@/components/select2";
@@ -1,5 +1,5 @@
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
@@ -1,5 +1,5 @@
import { Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
@@ -1,5 +1,5 @@
import { Thermometer } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
@@ -1,5 +1,5 @@
import { Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ServerMetrics } from "@/main-axios";
import { MetricCard } from "@/components/metric-card";
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { Clock4, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import { ShieldCheck, Plus, Trash2, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost, type FirewallChain } from "@/main-axios";
import { type FirewallChain } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -8,11 +8,11 @@ import {
Play,
Pencil,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Sparkline } from "@/components/charts";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { Select2 } from "@/components/select2";
@@ -1,7 +1,7 @@
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 { useTranslation } from "@termix/plugin-sdk/frontend";
import { managerGet, managerGetSub } from "../../host-metrics-api";
import { extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { RefreshCw, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { MetricCard } from "@/components/metric-card";
import type { ManagerError } from "./useManagerData";
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
/**
* Compact search row shared by manager cards. Optional `extra` renders to the
@@ -1,9 +1,9 @@
import { useMemo, useState } from "react";
import { Package, ArrowUpCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { ListTree, X, Skull } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { Cog, Play, Square, RotateCw, Power } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { MemoryStick, Timer, HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { BarSeries, StatRow } from "@/components/charts";
import { useManagerData } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { ShieldCheck, RefreshCw, Plus, X, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import {
useManagerData,
useManagerAction,
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Power } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useManagerData, useManagerAction } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -1,9 +1,9 @@
import { useMemo, useState } from "react";
import { Users, Plus, Trash2, ShieldCheck, Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { managerPost } from "@/main-axios";
import { managerPost } from "../../host-metrics-api";
import { useManagerData, extractError } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
import { ManagerSearch } from "./ManagerToolbar";
@@ -6,7 +6,7 @@ import {
ArrowUp,
ArrowDown,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useManagerData, useManagerAction } from "./useManagerData";
import { ManagerCardShell } from "./ManagerCardShell";
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { managerGet, managerPost } from "@/main-axios";
import { managerGet, managerPost } from "../../host-metrics-api";
interface ManagerError {
message: string;
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getHostMetricsLayout, saveHostMetricsLayout } from "@/main-axios";
import {
getHostMetricsLayout,
saveHostMetricsLayout,
} from "../host-metrics-api";
import type { HostMetricsLayout } from "@/types/host-metrics";
const SAVE_DEBOUNCE_MS = 800;
@@ -0,0 +1,146 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- a synced
// host has a different numeric id on each side (only its syncId matches
// across them). These calls always target the embedded local backend; see
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
// call that IS safely merged across local + remote (a process-local,
// in-memory aggregate keyed by whichever host ids that process happens to
// know about, not a per-host lookup).
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface MetricsHistoryResponse {
rows: MetricsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getMetricsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<MetricsHistoryResponse> {
const res = await statsApi.get(`/metrics/history/${hostId}`, {
params: opts,
});
return res.data as MetricsHistoryResponse;
}
export async function getMetricsHistoryRetention(): Promise<number> {
const res = await statsApi.get("/global-settings/history");
return (res.data as { metricsHistoryRetentionDays: number })
.metricsHistoryRetentionDays;
}
export async function saveMetricsHistoryRetention(days: number): Promise<void> {
await statsApi.post("/global-settings/history", {
metricsHistoryRetentionDays: days,
});
}
/**
* Host Metrics layout persistence (server-synced per user/host) + the manager
* card endpoints. All routes live under the `/host-metrics/*` prefix on the
* stats app (port 30005).
*/
export async function getHostMetricsLayout(
hostId: number,
): Promise<HostMetricsLayout | null> {
try {
const res = await statsApi.get(`/host-metrics/preferences/${hostId}`, {
validateStatus: (status) => status === 200 || status === 404,
});
if (res.status === 404) return null;
return res.data?.layout ?? null;
} catch (error) {
handleApiError(error, "fetch host metrics layout");
throw error;
}
}
export async function saveHostMetricsLayout(
hostId: number,
layout: HostMetricsLayout,
): Promise<void> {
try {
await statsApi.post(`/host-metrics/preferences/${hostId}`, layout);
} catch (error) {
handleApiError(error, "save host metrics layout");
throw error;
}
}
// ─── Managers ───────────────────────────────────────────────────────────────
export interface PlatformInfo {
hasSystemd: boolean;
pkg: "apt" | "dnf" | "yum" | "pacman" | null;
hasCertbot: boolean;
hasAcmeSh: boolean;
hasDocker: boolean;
osPrettyName: string | null;
}
export async function getHostPlatform(hostId: number): Promise<PlatformInfo> {
const res = await statsApi.get(`/host-metrics/platform/${hostId}`);
return res.data;
}
/** GET a manager resource (read). */
export async function managerGet<T>(
hostId: number,
resource: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}`,
{ params },
);
return res.data as T;
}
/**
* GET a manager sub-resource where the host id sits in the middle of the path,
* e.g. /managers/logs/{id}/files.
*/
export async function managerGetSub<T>(
hostId: number,
resource: string,
sub: string,
params?: Record<string, string | number>,
): Promise<T> {
const res = await statsApi.get(
`/host-metrics/managers/${resource}/${hostId}/${sub}`,
{ params },
);
return res.data as T;
}
/**
* POST a manager action. `resource` is the manager name; `action` is the
* optional sub-path (e.g. "action", "signal", "renew"). The host id always
* sits between them: /managers/{resource}/{id}[/{action}].
*/
export async function managerPost<T>(
hostId: number,
resource: string,
body: unknown,
action?: string,
): Promise<T> {
const suffix = action ? `/${action}` : "";
const res = await statsApi.post(
`/host-metrics/managers/${resource}/${hostId}${suffix}`,
body,
);
return res.data as T;
}
@@ -1,49 +0,0 @@
/**
* 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);
}
+161
View File
@@ -0,0 +1,161 @@
import type { ComponentType } from "react";
import { Activity, HardDrive, Server } from "lucide-react";
import type {
HomepageWidgetContribution,
HostEditorSectionProps,
StandaloneViewProps,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "@/types";
import { HostMetricsTab } from "./HostMetricsTab";
import HostMetricsApp from "./HostMetricsApp";
import { HostStatsTab } from "./HostEditorStatsTab";
import { metricsChartWidget } from "./MetricsChartWidget";
import { MetricsRetentionSetting } from "./MetricsRetentionSetting";
import { ProxmoxStatsTab } from "./proxmox-stats/ProxmoxStatsTab";
import ProxmoxStatsApp from "./proxmox-stats/ProxmoxStatsApp";
import { HostProxmoxStatsTab } from "./proxmox-stats/HostProxmoxStatsTab";
type SectionSetField = Parameters<typeof HostStatsTab>[0]["setField"];
function MetricsTab({ sshHost, label, isVisible }: TabProps) {
return (
<HostMetricsTab
hostConfig={sshHost as unknown as SSHHost}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>
);
}
function ProxmoxTab({ sshHost, label, isVisible }: TabProps) {
return (
<ProxmoxStatsTab
hostConfig={sshHost as unknown as SSHHost}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>
);
}
function MetricsStandalone({ hostId }: StandaloneViewProps) {
return <HostMetricsApp hostId={hostId} />;
}
function ProxmoxStandalone({ hostId }: StandaloneViewProps) {
return <ProxmoxStatsApp hostId={hostId} />;
}
function MetricsHostSection({
form,
setField,
snippets,
}: HostEditorSectionProps) {
return (
<HostStatsTab
form={form}
setField={setField as SectionSetField}
snippets={(snippets ?? []) as { id: number; name: string }[]}
/>
);
}
/** Drawn inside the proxmox plugin's host editor tab, when it runs. */
function ProxmoxStatsHostSection({
form,
setField,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
}) {
return (
<HostProxmoxStatsTab form={form} setField={setField as SectionSetField} />
);
}
export function activate(app: TermixApp): void {
app.registerTab("host-metrics", MetricsTab, {
icon: Server,
titleKey: "nav.hostMetrics",
requiresHost: true,
noHostMessageKey: "hostMetrics.noHostSelected",
persistent: true,
activityTypes: ["server_stats"],
standalone: MetricsStandalone,
// Links copied before the rename still open.
standaloneViews: ["server-stats"],
preload: () => import("./HostMetricsTab"),
});
app.registerTab("proxmox-stats", ProxmoxTab, {
icon: HardDrive,
titleKey: "nav.proxmoxStats",
requiresHost: true,
noHostMessageKey: "proxmoxStats.noHostSelected",
standalone: ProxmoxStandalone,
preload: () => import("./proxmox-stats/ProxmoxStatsTab"),
});
app.registerHostAction({
id: "host-metrics",
titleKey: "nav.hostMetrics",
icon: Server,
kind: "open",
order: 50,
tabType: "host-metrics",
copyUrlView: "host-metrics",
overview: true,
when: (host) =>
!!host.enableSsh &&
(host.statsConfig as { metricsEnabled?: boolean } | undefined)
?.metricsEnabled !== false,
});
app.registerHostAction({
id: "proxmox-stats",
titleKey: "nav.proxmoxStats",
icon: HardDrive,
kind: "open",
order: 60,
tabType: "proxmox-stats",
copyUrlView: "proxmox-stats",
when: (host) => host.enableProxmoxStats === true,
});
app.registerHostEditorSection({
id: "host-metrics",
group: "ssh",
titleKey: "hosts.tabHostMetrics",
icon: Activity,
order: 70,
component: MetricsHostSection,
});
app.registerSlotContribution("proxmox.hostEditor", {
actionId: "host-metrics.proxmoxStats",
titleKey: "nav.proxmoxStats",
kind: "component",
component: ProxmoxStatsHostSection as unknown as ComponentType<
Record<string, unknown>
>,
});
app.registerHomepageWidget(
metricsChartWidget as unknown as HomepageWidgetContribution,
);
app.registerSettingsComponent("retention", MetricsRetentionSetting);
app.registerSlotContribution("onboarding.features", {
actionId: "host-metrics.feature",
titleKey: "onboarding.feature_metrics",
descriptionKey: "onboarding.feature_metrics_desc",
icon: Activity as ComponentType<{ className?: string }>,
});
}
@@ -0,0 +1,261 @@
import { useMemo, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Box, Search, Server as ServerIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/input";
import type { ProxmoxGuestSummary } from "@/types/proxmox";
type StatusFilter = "all" | "running" | "stopped";
type TypeFilter = "all" | "qemu" | "lxc";
function formatUptime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function UsageCell({
percent,
usedGiB,
totalGiB,
}: {
percent: number | null;
usedGiB?: number | null;
totalGiB?: number | null;
}) {
if (percent === null) {
return <span className="text-[11px] text-muted-foreground">-</span>;
}
const clamped = Math.min(100, Math.max(0, percent));
const barColor =
clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-[92px] flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold tabular-nums">
{clamped.toFixed(0)}%
</span>
{usedGiB != null && totalGiB != null && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return guests
.filter(
(g) =>
statusFilter === "all" ||
(statusFilter === "running") === (g.status === "running"),
)
.filter((g) => typeFilter === "all" || g.type === typeFilter)
.filter(
(g) =>
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
)
.sort((a, b) => {
if (a.status === b.status) return a.name.localeCompare(b.name);
return a.status === "running" ? -1 : 1;
});
}, [guests, query, statusFilter, typeFilter]);
const running = guests.filter((g) => g.status === "running").length;
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("proxmoxStats.guestsSummary")}
</span>
<span className="text-[11px] text-muted-foreground">
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("proxmoxStats.searchGuests")}
className="h-7 w-40 pl-6 text-xs"
/>
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
<button
key={f}
onClick={() => setTypeFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
typeFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "qemu"
? t("proxmoxStats.filterVm")
: t("proxmoxStats.filterLxc")}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
statusFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</button>
))}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{guests.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuests")}
</div>
) : filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuestsMatch")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.name")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.status")}
</th>
<th className="px-3 py-2 text-left font-semibold">ID</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.cpu")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.mem")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.disk")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.uptime")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((guest) => (
<tr
key={`${guest.type}-${guest.vmid}`}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{guest.type === "lxc" ? (
<Box className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate font-semibold">
{guest.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
{guest.type === "lxc" ? "LXC" : "VM"}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
guest.status === "running"
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span
className={cn(
"font-medium",
guest.status === "running"
? "text-foreground"
: "text-muted-foreground",
)}
>
{guest.status === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</span>
</div>
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{guest.vmid}
</td>
<td className="px-3 py-2">
<UsageCell percent={guest.cpuPercent} />
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.memPercent}
usedGiB={guest.memUsedGiB}
totalGiB={guest.memTotalGiB}
/>
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.diskPercent}
usedGiB={guest.diskUsedGiB}
totalGiB={guest.diskTotalGiB}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(guest.uptimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostProxmoxStatsTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.enableProxmoxStats")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
>
<FakeSwitch
checked={form.enableProxmoxStats}
onChange={(v) => setField("enableProxmoxStats", v)}
/>
</SettingRow>
{form.enableProxmoxStats && (
<>
<SettingRow
label={t("hosts.proxmoxStatsPollInterval")}
description={t("hosts.proxmoxStatsPollIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={15}
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
pollInterval: Math.max(
15,
Number.parseInt(e.target.value || "60", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxStatsNodeOverride")}
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={form.proxmoxStatsConfig?.nodeName ?? ""}
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
nodeName: e.target.value || null,
})
}
/>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,147 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ProxmoxNodeStats } from "@/types/proxmox";
function Tile({
icon,
label,
percent,
detail,
sparkline,
}: {
icon: React.ReactNode;
label: string;
percent: number | null;
detail?: string | null;
sparkline?: number[];
}) {
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
const barColor =
clamped === null
? "bg-muted-foreground/30"
: clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<span className="text-[10px] font-bold uppercase tracking-widest">
{label}
</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-bold tabular-nums">
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
</span>
{detail && (
<span className="truncate text-[11px] text-muted-foreground">
{detail}
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-all", barColor)}
style={{ width: `${clamped ?? 0}%` }}
/>
</div>
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
</div>
);
}
function MiniSparkline({ data }: { data: number[] }) {
const w = 100;
const h = 20;
const max = 100;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
export function NodeSummaryStrip({
node,
histories,
}: {
node: ProxmoxNodeStats | undefined;
histories: { cpu: number[]; memory: number[]; disk: number[] };
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Tile
icon={<Cpu className="size-3.5" />}
label={t("proxmoxStats.cpuUsage")}
percent={node?.cpu.percent ?? null}
detail={
node?.cpu.cores != null
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
: undefined
}
sparkline={histories.cpu}
/>
<Tile
icon={<MemoryStick className="size-3.5" />}
label={t("proxmoxStats.memoryUsage")}
percent={node?.memory.percent ?? null}
detail={
node?.memory.usedGiB != null && node?.memory.totalGiB != null
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.memory}
/>
<Tile
icon={<HardDrive className="size-3.5" />}
label={t("proxmoxStats.diskUsage")}
percent={node?.disk.percent ?? null}
detail={
node?.disk.usedGiB != null && node?.disk.totalGiB != null
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.disk}
/>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t("proxmoxStats.uptime")}
</span>
</div>
<span className="text-2xl font-bold">
{node?.uptime.formatted ?? "N/A"}
</span>
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
<Server className="size-3 shrink-0" />
<span className="truncate">
{node?.system.hostname ?? "-"}
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,52 @@
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ProxmoxStatsTab } from "./ProxmoxStatsTab";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface ProxmoxStatsAppProps {
hostId?: string;
}
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ 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 (
<ProxmoxStatsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default ProxmoxStatsApp;
@@ -0,0 +1,385 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import { Button } from "@/components/button.tsx";
import { getSSHHosts } from "@/main-axios";
import {
getProxmoxStats,
startProxmoxStatsPolling,
stopProxmoxStatsPolling,
sendProxmoxStatsHeartbeat,
} from "./proxmox-stats-api";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import type { ProxmoxStatsConfig } from "@/types";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { RefreshCw, Server } from "lucide-react";
import { NodeSummaryStrip } from "./NodeSummaryStrip";
import { GuestTable } from "./GuestTable";
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useConnectionRetry } from "@/lib/useConnectionRetry.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
const HISTORY_LEN = 30;
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.node.cpu.percent),
memory: bucket(data.node.memory.percent),
disk: bucket(data.node.disk.percent),
guests: data.guests.counts,
cluster: data.cluster,
});
}
const DEFAULT_POLL_INTERVAL = 60;
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
authType?: string;
port?: number;
[key: string]: unknown;
}
interface ProxmoxStatsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
interface ProxmoxStatsHistories {
cpu: number[];
memory: number[];
disk: number[];
}
function parseProxmoxStatsConfig(
raw?: string | ProxmoxStatsConfig | null,
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
if (!raw) return defaults;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...defaults, ...parsed };
} catch {
return defaults;
}
}
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ProxmoxStatsInner {...props} />
</ConnectionLogProvider>
);
}
function ProxmoxStatsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ProxmoxStatsProps): React.ReactElement {
const { t } = useTranslation();
const { addLog, clearLogs } = useConnectionLog();
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
null,
);
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
cpu: [],
memory: [],
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const statsConfig = React.useMemo(
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
[currentHostConfig?.proxmoxStatsConfig],
);
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(() => {
sendProxmoxStatsHeartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setSnapshot(null);
setHistories({ cpu: [], memory: [], disk: [] });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatest = async () => {
if (!hostConfig?.id) return;
try {
const hosts = await getSSHHosts();
const updated = hosts.find((h) => h.id === hostConfig.id);
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
} catch {
// keep previous config
}
};
fetchLatest();
const onChanged = () => fetchLatest();
window.addEventListener("ssh-hosts:changed", onChanged);
return () => window.removeEventListener("ssh-hosts:changed", onChanged);
}, [hostConfig?.id]);
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.node?.cpu?.percent),
memory: add(prev.memory, data.node?.memory?.percent),
disk: add(prev.disk, data.node?.disk?.percent),
};
});
}, []);
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
const stopPollingRef = React.useRef<(() => void) | null>(null);
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
addLog({
type: "info",
stage: "stats_connecting",
message: t("proxmoxStats.connecting"),
});
const result = await startProxmoxStatsPolling(currentHostConfig.id);
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
addLog({
type: "info",
stage: "stats_polling",
message: t("proxmoxStats.connecting"),
});
const data = await getProxmoxStats(currentHostConfig.id);
if (!data) {
throw new Error(t("proxmoxStats.connectionFailed"));
}
setSnapshot(data);
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
const intervalMs =
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
let signature = statsChangeKey(data);
stopPollingRef.current?.();
stopPollingRef.current = runAdaptivePolling(
async () => {
const next = await getProxmoxStats(currentHostConfig.id);
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
const nextSignature = statsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setSnapshot(next);
pushHistory(next);
return changed;
},
{
minIntervalMs: intervalMs,
maxIntervalMs: Math.min(120_000, intervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [currentHostConfig?.id, statsConfig.pollInterval, addLog, t, pushHistory]);
const retry = useConnectionRetry({
connect: async () => {
try {
await fetchSnapshot();
retry.markConnected();
} catch (error: unknown) {
addLog({
type: "error",
stage: "error",
message:
error instanceof Error
? error.message
: t("proxmoxStats.connectionFailed"),
});
retry.markFailed();
}
},
enabled: isPageVisible && !notEnabled && !!currentHostConfig?.id,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
// 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 (notEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const debounce = setTimeout(() => {
if (isPageVisible && !cancelled) {
clearLogs();
retryRef.current.reset();
retryRef.current.retryNow();
} else if (!isPageVisible) {
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
stopProxmoxStatsPolling(currentHostConfig.id, undefined).catch(
() => {},
);
}
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounce);
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
stopProxmoxStatsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, notEnabled, isPageVisible]);
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 (retry.status !== "connected") {
retry.retryNow();
return;
}
try {
setIsRefreshing(true);
const data = await getProxmoxStats(currentHostConfig.id);
if (data) {
setSnapshot(data);
pushHistory(data);
}
} finally {
setIsRefreshing(false);
}
};
const showContent = !notEnabled && retry.status === "connected" && snapshot;
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{retry.status === "connected" && !notEnabled && (
<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>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("proxmoxStats.refresh")}
</Button>
</div>
)}
{showContent && (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
<div className="shrink-0">
<NodeSummaryStrip node={snapshot.node} histories={histories} />
</div>
<GuestTable guests={snapshot.guests.guests} />
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
<NodeNetworkCard snapshot={snapshot} />
<StoragePoolsCard snapshot={snapshot} />
{snapshot.cluster.clustered && (
<ClusterHealthCard snapshot={snapshot} />
)}
</div>
</div>
)}
<ConnectionScreen
status={notEnabled ? "connected" : retry.status}
message={t("proxmoxStats.connecting")}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={retry.retryNow}
emptyState={
notEnabled ? (
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("proxmoxStats.noHostSelected")}
</p>
</div>
) : undefined
}
/>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { cn } from "@/lib/utils";
import { MetricCard } from "@/components/metric-card";
export function ClusterHealthCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const cluster = snapshot?.cluster;
return (
<MetricCard
title={t("proxmoxStats.clusterHealth")}
icon={<Network className="size-3.5" />}
scroll
>
{!cluster || !cluster.clustered ? (
<span className="text-xs text-muted-foreground">
{t("proxmoxStats.notClustered")}
</span>
) : (
<div className="flex flex-col gap-2 text-xs">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
cluster.quorate ? "bg-green-500" : "bg-red-500",
)}
/>
<span className="font-medium">
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
</span>
<span className="text-muted-foreground">
{cluster.quorate
? t("proxmoxStats.quorate")
: t("proxmoxStats.notQuorate")}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{cluster.nodes.map((node) => (
<div key={node.name} className="flex items-center gap-2 py-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
node.online ? "bg-green-500" : "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{node.online
? t("proxmoxStats.nodeOnline")
: t("proxmoxStats.nodeOffline")}
</span>
</div>
))}
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,37 @@
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { StatRow } from "@/components/charts";
import { MetricCard } from "@/components/metric-card";
export function NodeNetworkCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const interfaces = snapshot?.network.interfaces ?? [];
return (
<MetricCard
title={t("proxmoxStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll
>
{interfaces.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{interfaces.map((iface) => (
<StatRow
key={iface.name}
label={iface.name}
value={iface.ip ?? iface.state ?? "-"}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,60 @@
import { Database } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
import { MetricCard } from "@/components/metric-card";
export function StoragePoolsCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const pools = snapshot?.storage.pools ?? [];
return (
<MetricCard
title={t("proxmoxStats.storagePools")}
icon={<Database className="size-3.5" />}
scroll
>
{pools.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{pools.map((pool) => (
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{pool.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
{pool.type}
</span>
{!pool.active && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("proxmoxStats.inactive")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-accent-brand"
style={{
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{pool.usedGiB !== null && pool.totalGiB !== null
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,93 @@
import axios from "axios";
import { handleApiError, statsApi } from "@/main-axios";
import type { ProxmoxStatsSnapshot } from "@/types/proxmox";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- same caveat as
// host-metrics-api.ts / host-metrics-status-api.ts. All routes live under the
// `/proxmox-stats/*` prefix on the stats app (port 30005).
export async function getProxmoxStats(
hostId: number,
): Promise<ProxmoxStatsSnapshot | null> {
try {
const response = await statsApi.get(`/proxmox-stats/${hostId}`, {
// Treat 404 as an expected "no stats yet / disabled" signal rather than
// an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch proxmox stats");
throw error;
}
}
export async function startProxmoxStatsPolling(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}> {
try {
const response = await statsApi.post(`/proxmox-stats/start/${hostId}`);
return response.data;
} catch (error) {
handleApiError(error, "start proxmox stats polling");
throw error;
}
}
export async function stopProxmoxStatsPolling(
hostId: number,
viewerSessionId?: string,
): Promise<void> {
try {
await statsApi.post(`/proxmox-stats/stop/${hostId}`, { viewerSessionId });
} catch (error) {
handleApiError(error, "stop proxmox stats polling");
throw error;
}
}
export async function sendProxmoxStatsHeartbeat(
viewerSessionId: string,
): Promise<void> {
try {
await statsApi.post("/proxmox-stats/heartbeat", { viewerSessionId });
} catch (error) {
handleApiError(error, "send proxmox stats heartbeat");
throw error;
}
}
export interface ProxmoxStatsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface ProxmoxStatsHistoryResponse {
rows: ProxmoxStatsHistoryRow[];
fromTs: string;
toTs: string;
}
export async function getProxmoxStatsHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<ProxmoxStatsHistoryResponse> {
const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, {
params: opts,
});
return res.data as ProxmoxStatsHistoryResponse;
}