feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

This commit is contained in:
LukeGus committed 2026-09-23 13:06:06 -05:00
1 parent f9283ec83f
commit 218c5088e7
20 files changed
+194 -1329

No files matched your search

+48 -23
View File
@@ -1029,29 +1029,29 @@ deciding, not the mechanism.
Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| Member | Capability | Status | | Member | Capability | Status |
| ------------------------------------------------ | ------------------------------------ | ------ | | ------------------------------------------------ | ------------------------------------ | ----------------------- |
| `ctx.pluginId`, `ctx.manifest` | none | **A1** | | `ctx.pluginId`, `ctx.manifest` | none | **A1** |
| `ctx.log.*` | none | **A1** | | `ctx.log.*` | none | **A1** |
| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | | `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** |
| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | | `ctx.kv.get/set/delete/list` | `kv:own` | **A1** |
| `ctx.registry.*` | none | **A1** | | `ctx.registry.*` | none | **A1** |
| `ctx.services.provide` / `.get` | per-service RBAC | **A1** | | `ctx.services.provide` / `.get` | per-service RBAC | **A1** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | | `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.disposables.add` | none | **A1** | | `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** | | `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** | | `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | | `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** | | `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
| `ctx.sync.registerEntity` | none | **A3** | | `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | | `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | | `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4** | | `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | | `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 | | `ctx.notify.*` | `notify:send` | A6 |
| `ctx.auth.*` | `auth:provide` | **A8** | | `ctx.auth.*` | `auth:provide` | **A8** |
| `ctx.fetch` | `network:outbound` | B | | `ctx.fetch` | `network:outbound` | B |
`ctx.ssh` was pulled forward from B so plugin transports go through core's `ctx.ssh` was pulled forward from B so plugin transports go through core's
connect pipeline instead of importing ssh2 helpers from core: connect pipeline instead of importing ssh2 helpers from core:
@@ -1100,6 +1100,31 @@ that needs to actually connect uses `ctx.ssh.connect(hostId)` or
`withConnection(hostId, ...)`, which resolve the full host (secrets included) `withConnection(hostId, ...)`, which resolve the full host (secrets included)
through the one connect pipeline. fleets never holds a `PluginSshHost` itself. through the one connect pipeline. fleets never holds a `PluginSshHost` itself.
**B5** added `create`, `update` and `listOwned`, for a plugin that creates or
maintains hosts on the user's behalf rather than only sharing access to
existing ones (proxmox's discovery and sync flow is the first caller):
- `create(host)` inserts a host owned by the acting user, encrypted the same
way the host editor's own create route does. Takes `PluginHostCreateInput`
(name, ip, port, username, authType required; connection flags, tags,
folder, jumpHosts and the rest optional) and returns the decrypted
`PluginHostRecord`. Needs `hosts:write`.
- `update(hostId, patch)` updates a host the acting user owns, refusing one it
does not (sharing a write onto someone else's host goes through `share()`,
never `update()`). Returns `null` for a host that does not exist or is not
owned by the caller. Needs `hosts:write`.
- `listOwned()` returns every host the acting user owns as full
`PluginHostRecord`s (credentialId, jumpHosts and the rest `list()`'s
narrower `PluginHostSummary` leaves out), for a plugin scanning its own
hosts in the background, such as proxmox's auto-sync sweep. Needs
`hosts:write`, matching `create`/`update`: this is the same "full host
detail" surface those calls need to read back, not a wider read grant than
`hosts:read` gives through `list`/`get`.
`PluginHostRecord` carries no secret auth material (password, key, vault
token): a plugin that creates a host picks an `authType` and, for
`"credential"`, a `credentialId` it does not need to see the contents of.
### The actor ### The actor
Never comes from plugin code. It is held in `AsyncLocalStorage` and set two Never comes from plugin code. It is held in `AsyncLocalStorage` and set two
+17
View File
@@ -23,3 +23,20 @@ it's done.
is the SDK copy fleets now uses; core's copy was left in place rather than is the SDK copy fleets now uses; core's copy was left in place rather than
turned into a re-export, since host-metrics's own conversion step is where turned into a re-export, since host-metrics's own conversion step is where
its imports should move wholesale. Owner: host-metrics's Phase B step. its imports should move wholesale. Owner: host-metrics's Phase B step.
- **B5 (proxmox):** `useConnectionRetry`, `runAdaptivePolling` and `cn` are
duplicated in `plugins/proxmox/src/frontend/stats/` rather than shared with
core's `src/ui/lib/` originals, matching the same "add to the SDK once a
second plugin needs it" call B3 made for `ManagerCardShell`. host-metrics's
own conversion needs the same two hooks for its stats tab, so promote both
into `@termix/plugin-sdk/frontend` (and `cn` into `@termix/plugin-sdk/ui`)
the next time either is touched. Owner: D1, or host-metrics's Phase B step
if it lands first.
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
`plugins/proxmox/src/backend/routes.ts` still reaches
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
relative import instead of `ctx`, because it needs to scan every host's
Proxmox settings across every user before it knows which actor to run each
sync as, and neither `ctx.settings` nor `ctx.hosts` expose a cross-user
bulk read today. A `ctx.settings.listHostsWithKey` or similar SDK addition
would close this, if a second plugin's background scan ever needs the same
shape. Owner: D1.
+74
View File
@@ -409,6 +409,56 @@ export interface PluginHostSummary {
authType: string; authType: string;
} }
/**
* A host record wide enough for a plugin that creates or updates hosts on the
* user's behalf (import, discovery, sync), decrypted for that user. Unlike
* PluginHostSummary this carries the fields a plugin needs to set up a
* connectable host, not just what a list view shows. Still no secret auth
* material (password, key, vault tokens): a plugin that creates a host picks
* an authType and, for "credential", a credentialId it does not need to see
* the contents of.
*/
export interface PluginHostRecord {
id: number;
userId: string;
name: string | null;
ip: string;
port: number;
username: string;
authType: string;
credentialId?: number | null;
overrideCredentialUsername?: boolean | null;
connectionType?: string | null;
tags: string | null;
folder: string | null;
jumpHosts?: unknown;
enableSsh?: boolean | null;
enableRdp?: boolean | null;
enableTerminal?: boolean | null;
enableFileManager?: boolean | null;
enableTunnel?: boolean | null;
enableDocker?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
[key: string]: unknown;
}
/** Fields a plugin may set when creating a host it will own or manage. */
export type PluginHostCreateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
> & {
name: string;
ip: string;
port: number;
username: string;
authType: string;
};
/** Fields a plugin may change on a host it already created or was granted access to. */
export type PluginHostUpdateInput = Partial<
Omit<PluginHostRecord, "id" | "userId">
>;
/** The same shape canAccessHost returns internally, without secrets. */ /** The same shape canAccessHost returns internally, without secrets. */
export interface PluginHostAccess { export interface PluginHostAccess {
hasAccess: boolean; hasAccess: boolean;
@@ -462,6 +512,30 @@ export interface PluginHosts {
hostId: number, hostId: number,
level: PluginHostShareLevel, level: PluginHostShareLevel,
) => Promise<PluginHostAccess>; ) => Promise<PluginHostAccess>;
/**
* Creates a host owned by the acting user, encrypted the same way the host
* editor's own create route does it. Needs hosts:write. For a plugin that
* imports or discovers hosts on the user's behalf (B5's proxmox discovery).
*/
create: (host: PluginHostCreateInput) => Promise<PluginHostRecord>;
/**
* Updates a host the acting user owns. Refuses a host it does not own
* (sharing a write onto someone else's host goes through share(), not
* update()). Needs hosts:write.
*/
update: (
hostId: number,
patch: PluginHostUpdateInput,
) => Promise<PluginHostRecord | null>;
/**
* Every host the acting user owns, decrypted and wide (PluginHostRecord,
* not the narrow list() summary). For a plugin that scans its own hosts in
* the background (auto-sync) and needs fields list() does not carry, such
* as credentialId or jumpHosts. Needs hosts:write, matching create/update:
* this is the same "full host detail" surface a create/update caller needs
* to read back, not a wider read grant than hosts:read gives via list/get.
*/
listOwned: () => Promise<PluginHostRecord[]>;
/** /**
* Grants access to a host the caller manages, snapshotting shared secrets * Grants access to a host the caller manages, snapshotting shared secrets
* for each target the same way the host editor's own share action does. * for each target the same way the host editor's own share action does.
+3
View File
@@ -20,6 +20,7 @@ export type PluginColumnType =
| "varchar" | "varchar"
| "integer" | "integer"
| "bigint" | "bigint"
| "real"
| "boolean" | "boolean"
| "timestamp" | "timestamp"
| "json" | "json"
@@ -77,6 +78,8 @@ export const varchar = (length: number = KEY_LENGTH) =>
export const integer = () => builder({ type: "integer" }); export const integer = () => builder({ type: "integer" });
export const bigint = () => builder({ type: "bigint" }); export const bigint = () => builder({ type: "bigint" });
/** Floating point. A plain number on every engine. Never indexable. */
export const real = () => builder({ type: "real" });
export const boolean = () => builder({ type: "boolean" }); export const boolean = () => builder({ type: "boolean" });
/** Stored as text on every engine. See src/backend/database/repositories/sql-timestamp.ts. */ /** Stored as text on every engine. See src/backend/database/repositories/sql-timestamp.ts. */
+3
View File
@@ -62,6 +62,9 @@ function columnType(
return dialect === "mysql" ? "int" : "integer"; return dialect === "mysql" ? "int" : "integer";
case "bigint": case "bigint":
return dialect === "sqlite" ? "integer" : "bigint"; return dialect === "sqlite" ? "integer" : "bigint";
case "real":
if (dialect === "sqlite") return "real";
return dialect === "mysql" ? "double" : "double precision";
case "boolean": case "boolean":
return dialect === "sqlite" ? "integer" : "boolean"; return dialect === "sqlite" ? "integer" : "boolean";
case "varchar": case "varchar":
+4
View File
@@ -12,6 +12,7 @@ import {
sqliteTable, sqliteTable,
text as sqliteText, text as sqliteText,
integer as sqliteInteger, integer as sqliteInteger,
real as sqliteReal,
index as sqliteIndex, index as sqliteIndex,
uniqueIndex as sqliteUniqueIndex, uniqueIndex as sqliteUniqueIndex,
type AnySQLiteColumn, type AnySQLiteColumn,
@@ -55,6 +56,9 @@ export function buildTable(
case "refHost": case "refHost":
built = sqliteInteger(name); built = sqliteInteger(name);
break; break;
case "real":
built = sqliteReal(name);
break;
case "boolean": case "boolean":
built = sqliteInteger(name, { mode: "boolean" }); built = sqliteInteger(name, { mode: "boolean" });
break; break;
+44
View File
@@ -24,6 +24,9 @@ import type {
PluginSshAuthProvider, PluginSshAuthProvider,
PluginSshHost, PluginSshHost,
PluginHostSummary, PluginHostSummary,
PluginHostRecord,
PluginHostCreateInput,
PluginHostUpdateInput,
PluginHostAccess, PluginHostAccess,
PluginHostShareResult, PluginHostShareResult,
PluginShareableUser, PluginShareableUser,
@@ -174,6 +177,8 @@ export function createFakeContext(
const hostsById = new Map<number, PluginHostSummary>( const hostsById = new Map<number, PluginHostSummary>(
(options.hosts ?? []).map((h) => [h.id, h]), (options.hosts ?? []).map((h) => [h.id, h]),
); );
const hostRecordsById = new Map<number, PluginHostRecord>();
let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1;
const services = new Map<string, object>(); const services = new Map<string, object>();
const auth: FakeAuthRegistrations = { const auth: FakeAuthRegistrations = {
sshAuthProviders: [], sshAuthProviders: [],
@@ -421,6 +426,33 @@ export function createFakeContext(
permissionLevel: "manage", permissionLevel: "manage",
}; };
}, },
create: async (
host: PluginHostCreateInput,
): Promise<PluginHostRecord> => {
const id = nextHostId++;
const record: PluginHostRecord = {
tags: null,
folder: null,
...host,
id,
userId: actor ?? "unknown",
};
hostRecordsById.set(id, record);
return record;
},
update: async (
hostId: number,
patch: PluginHostUpdateInput,
): Promise<PluginHostRecord | null> => {
const existing = hostRecordsById.get(hostId);
if (!existing) return null;
const updated = { ...existing, ...patch };
hostRecordsById.set(hostId, updated);
return updated;
},
listOwned: async (): Promise<PluginHostRecord[]> => [
...hostRecordsById.values(),
],
share: async ( share: async (
hostId, hostId,
targets, targets,
@@ -706,6 +738,18 @@ export function createMockCtx(
require("hosts:read"); require("hosts:read");
return ctx.hosts.checkAccess(hostId, level); return ctx.hosts.checkAccess(hostId, level);
}, },
create: async (host) => {
require("hosts:write");
return ctx.hosts.create(host);
},
update: async (hostId, patch) => {
require("hosts:write");
return ctx.hosts.update(hostId, patch);
},
listOwned: async () => {
require("hosts:write");
return ctx.hosts.listOwned();
},
share: async (hostId, targets, permissionLevel, durationHours) => { share: async (hostId, targets, permissionLevel, durationHours) => {
require("hosts:write"); require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours); return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
-6
View File
@@ -33,12 +33,6 @@
"titleKey": "nav.hostMetrics", "titleKey": "nav.hostMetrics",
"icon": "Activity", "icon": "Activity",
"openFrom": ["host-context-menu", "palette"] "openFrom": ["host-context-menu", "palette"]
},
{
"id": "proxmox-stats",
"titleKey": "nav.proxmoxStats",
"icon": "HardDrive",
"openFrom": ["host-context-menu", "palette"]
} }
], ],
"settings": { "settings": {
@@ -46,9 +46,6 @@ import { registerHostMetricsSettingsRoutes } from "./settings-routes.js";
import { registerHostMetricsViewerRoutes } from "./viewer-routes.js"; import { registerHostMetricsViewerRoutes } from "./viewer-routes.js";
import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js"; import { registerHostMetricsPreferencesRoutes } from "./preferences-routes.js";
import { registerHostMetricsHistoryRoutes } from "./history-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 type { HostSessionStatusPayload } from "../../../../src/backend/hosts/host-session-status.js";
import { import {
pluginEvents, pluginEvents,
@@ -125,8 +122,6 @@ interface SSHHostWithCredentials {
tunnelConnections: unknown[]; tunnelConnections: unknown[];
jumpHosts?: Array<{ hostId: number }>; jumpHosts?: Array<{ hostId: number }>;
statsConfig?: string | StatsConfig; statsConfig?: string | StatsConfig;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: unknown;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
userId: string; userId: string;
@@ -1186,13 +1181,6 @@ async function withSshConnection<T>(
); );
} }
const proxmoxPollingManager = new ProxmoxPollingManager<SSHHostWithCredentials>(
{
fetchHostById,
withSshConnection,
},
);
async function collectMetrics( async function collectMetrics(
host: SSHHostWithCredentials, host: SSHHostWithCredentials,
onAuthenticated?: () => void, onAuthenticated?: () => void,
@@ -2453,40 +2441,6 @@ registerHostMetricsHistoryRoutes(app, {
(await permissionManager.canAccessHost(userId, hostId, level)).hasAccess, (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, { registerManagerRoutes(app, {
validateHostId, validateHostId,
runOnHost: async (hostId, userId, level, fn) => { runOnHost: async (hostId, userId, level, fn) => {
@@ -2554,7 +2508,6 @@ export function shutdown(): void {
cleanupInterval = undefined; cleanupInterval = undefined;
} }
pollingManager.destroy(); pollingManager.destroy();
proxmoxPollingManager.destroy();
// The pool is core's and shared with tmux and the fleet tools; destroying it // 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 // here broke them until a restart. ctx.ssh drops this plugin's pooled
// connections on deactivate instead. // connections on deactivate instead.
+1 -63
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { Activity, HardDrive, Server } from "lucide-react"; import { Activity, Server } from "lucide-react";
import type { import type {
HomepageWidgetContribution, HomepageWidgetContribution,
HostEditorSectionProps, HostEditorSectionProps,
@@ -13,9 +13,6 @@ import HostMetricsApp from "./HostMetricsApp";
import { HostStatsTab } from "./HostEditorStatsTab"; import { HostStatsTab } from "./HostEditorStatsTab";
import { metricsChartWidget } from "./MetricsChartWidget"; import { metricsChartWidget } from "./MetricsChartWidget";
import { MetricsRetentionSetting } from "./MetricsRetentionSetting"; 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"]; 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) { function MetricsStandalone({ hostId }: StandaloneViewProps) {
return <HostMetricsApp hostId={hostId} />; return <HostMetricsApp hostId={hostId} />;
} }
function ProxmoxStandalone({ hostId }: StandaloneViewProps) {
return <ProxmoxStatsApp hostId={hostId} />;
}
function MetricsHostSection({ function MetricsHostSection({
form, form,
setField, 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 { export function activate(app: TermixApp): void {
app.registerTab("host-metrics", MetricsTab, { app.registerTab("host-metrics", MetricsTab, {
icon: Server, icon: Server,
@@ -93,15 +60,6 @@ export function activate(app: TermixApp): void {
preload: () => import("./HostMetricsTab"), 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({ app.registerHostAction({
id: "host-metrics", id: "host-metrics",
titleKey: "nav.hostMetrics", titleKey: "nav.hostMetrics",
@@ -117,17 +75,6 @@ export function activate(app: TermixApp): void {
?.metricsEnabled !== false, ?.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({ app.registerHostEditorSection({
id: "host-metrics", id: "host-metrics",
group: "ssh", group: "ssh",
@@ -137,15 +84,6 @@ export function activate(app: TermixApp): void {
component: MetricsHostSection, 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( app.registerHomepageWidget(
metricsChartWidget as unknown as HomepageWidgetContribution, 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;
}
-9
View File
@@ -66,15 +66,6 @@
"plugins/host-metrics/src/frontend/host-metrics-api.ts", "plugins/host-metrics/src/frontend/host-metrics-api.ts",
"plugins/host-metrics/src/frontend/index.tsx", "plugins/host-metrics/src/frontend/index.tsx",
"plugins/host-metrics/src/frontend/metrics-change-key.ts", "plugins/host-metrics/src/frontend/metrics-change-key.ts",
"plugins/host-metrics/src/frontend/proxmox-stats/GuestTable.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/HostProxmoxStatsTab.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/NodeSummaryStrip.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsApp.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsTab.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/cards/ClusterHealthCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/cards/NodeNetworkCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx",
"plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts",
"plugins/host-metrics/src/frontend/temperature-preference.ts", "plugins/host-metrics/src/frontend/temperature-preference.ts",
"plugins/proxmox/src/frontend/HostProxmoxTab.tsx", "plugins/proxmox/src/frontend/HostProxmoxTab.tsx",
"plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx", "plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",