mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
This commit is contained in:
1 parent
f9283ec83f
commit
218c5088e7
20 files changed
+194
-1329
No files matched your search
@@ -33,12 +33,6 @@
|
||||
"titleKey": "nav.hostMetrics",
|
||||
"icon": "Activity",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
},
|
||||
{
|
||||
"id": "proxmox-stats",
|
||||
"titleKey": "nav.proxmoxStats",
|
||||
"icon": "HardDrive",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -46,9 +46,6 @@ import { registerHostMetricsSettingsRoutes } from "./settings-routes.js";
|
||||
import { registerHostMetricsViewerRoutes } from "./viewer-routes.js";
|
||||
import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js";
|
||||
import { registerHostMetricsHistoryRoutes } from "./history-routes.js";
|
||||
import { registerProxmoxStatsRoutes } from "../../../../src/backend/hosts/metrics/proxmox-stats-routes.js";
|
||||
import { registerProxmoxStatsHistoryRoutes } from "../../../../src/backend/hosts/metrics/proxmox-stats-history-routes.js";
|
||||
import { ProxmoxPollingManager } from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
|
||||
import type { HostSessionStatusPayload } from "../../../../src/backend/hosts/host-session-status.js";
|
||||
import {
|
||||
pluginEvents,
|
||||
@@ -125,8 +122,6 @@ interface SSHHostWithCredentials {
|
||||
tunnelConnections: unknown[];
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
statsConfig?: string | StatsConfig;
|
||||
enableProxmoxStats?: boolean;
|
||||
proxmoxStatsConfig?: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
@@ -1186,13 +1181,6 @@ async function withSshConnection<T>(
|
||||
);
|
||||
}
|
||||
|
||||
const proxmoxPollingManager = new ProxmoxPollingManager<SSHHostWithCredentials>(
|
||||
{
|
||||
fetchHostById,
|
||||
withSshConnection,
|
||||
},
|
||||
);
|
||||
|
||||
async function collectMetrics(
|
||||
host: SSHHostWithCredentials,
|
||||
onAuthenticated?: () => void,
|
||||
@@ -2453,40 +2441,6 @@ registerHostMetricsHistoryRoutes(app, {
|
||||
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess,
|
||||
});
|
||||
|
||||
registerHostMetricsViewerRoutes<
|
||||
SSHHostWithCredentials,
|
||||
{ metricsEnabled: boolean }
|
||||
>(app, {
|
||||
fetchHostById,
|
||||
// Proxmox Stats viewers are gated by enableProxmoxStats + host-type support,
|
||||
// not the Host Metrics statsConfig - fold both checks in here since
|
||||
// supportsMetrics receives the full host, unlike parseStatsConfig below.
|
||||
supportsMetrics: (host: SSHHostWithCredentials) =>
|
||||
supportsMetrics(host) && host.enableProxmoxStats === true,
|
||||
parseStatsConfig: () => ({ metricsEnabled: true }),
|
||||
updateHeartbeat: (viewerSessionId) =>
|
||||
proxmoxPollingManager.updateHeartbeat(viewerSessionId),
|
||||
registerViewer: (hostId, viewerSessionId, userId) =>
|
||||
proxmoxPollingManager.registerViewer(hostId, viewerSessionId, userId),
|
||||
unregisterViewer: (hostId, viewerSessionId) =>
|
||||
proxmoxPollingManager.unregisterViewer(hostId, viewerSessionId),
|
||||
pathPrefix: "proxmox-stats",
|
||||
});
|
||||
|
||||
registerProxmoxStatsRoutes(app, {
|
||||
validateHostId,
|
||||
fetchHostById,
|
||||
canAccessHost: async (userId, hostId, level) =>
|
||||
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess,
|
||||
pollingManager: proxmoxPollingManager,
|
||||
});
|
||||
|
||||
registerProxmoxStatsHistoryRoutes(app, {
|
||||
validateHostId,
|
||||
canAccessHost: async (userId, hostId, level) =>
|
||||
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess,
|
||||
});
|
||||
|
||||
registerManagerRoutes(app, {
|
||||
validateHostId,
|
||||
runOnHost: async (hostId, userId, level, fn) => {
|
||||
@@ -2554,7 +2508,6 @@ export function shutdown(): void {
|
||||
cleanupInterval = undefined;
|
||||
}
|
||||
pollingManager.destroy();
|
||||
proxmoxPollingManager.destroy();
|
||||
// The pool is core's and shared with tmux and the fleet tools; destroying it
|
||||
// here broke them until a restart. ctx.ssh drops this plugin's pooled
|
||||
// connections on deactivate instead.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Activity, HardDrive, Server } from "lucide-react";
|
||||
import { Activity, Server } from "lucide-react";
|
||||
import type {
|
||||
HomepageWidgetContribution,
|
||||
HostEditorSectionProps,
|
||||
@@ -13,9 +13,6 @@ 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"];
|
||||
|
||||
@@ -31,26 +28,10 @@ function MetricsTab({ sshHost, label, isVisible }: TabProps) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -65,20 +46,6 @@ function MetricsHostSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -93,15 +60,6 @@ export function activate(app: TermixApp): void {
|
||||
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",
|
||||
@@ -117,17 +75,6 @@ export function activate(app: TermixApp): void {
|
||||
?.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",
|
||||
@@ -137,15 +84,6 @@ export function activate(app: TermixApp): void {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
@@ -1,385 +0,0 @@
|
||||
/* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user