From 218c5088e7289f36aedd662ee9666ee1e2317357 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 23 Sep 2026 13:06:06 -0500 Subject: [PATCH] feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox --- packages/plugin-sdk/ARCHITECTURE.md | 71 ++-- packages/plugin-sdk/FINISH-LIST.md | 17 + packages/plugin-sdk/src/backend.ts | 74 ++++ packages/plugin-sdk/src/db.ts | 3 + packages/plugin-sdk/src/ddl.ts | 3 + packages/plugin-sdk/src/table-builder.ts | 4 + packages/plugin-sdk/src/testing.ts | 44 ++ plugins/host-metrics/manifest.json | 6 - plugins/host-metrics/src/backend/routes.ts | 47 --- plugins/host-metrics/src/frontend/index.tsx | 64 +-- .../src/frontend/proxmox-stats/GuestTable.tsx | 261 ------------ .../proxmox-stats/HostProxmoxStatsTab.tsx | 79 ---- .../proxmox-stats/NodeSummaryStrip.tsx | 147 ------- .../proxmox-stats/ProxmoxStatsApp.tsx | 52 --- .../proxmox-stats/ProxmoxStatsTab.tsx | 385 ------------------ .../proxmox-stats/cards/ClusterHealthCard.tsx | 67 --- .../proxmox-stats/cards/NodeNetworkCard.tsx | 37 -- .../proxmox-stats/cards/StoragePoolsCard.tsx | 60 --- .../proxmox-stats/proxmox-stats-api.ts | 93 ----- scripts/plugin-boundary-allowlist.json | 9 - 20 files changed, 194 insertions(+), 1329 deletions(-) delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/GuestTable.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/HostProxmoxStatsTab.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/NodeSummaryStrip.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsApp.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsTab.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/cards/ClusterHealthCard.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/cards/NodeNetworkCard.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx delete mode 100644 plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 1ef7be028..cb75d5f88 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -1029,29 +1029,29 @@ deciding, not the mechanism. Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. -| Member | Capability | Status | -| ------------------------------------------------ | ------------------------------------ | ------ | -| `ctx.pluginId`, `ctx.manifest` | none | **A1** | -| `ctx.log.*` | none | **A1** | -| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | -| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | -| `ctx.registry.*` | none | **A1** | -| `ctx.services.provide` / `.get` | per-service RBAC | **A1** | -| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | -| `ctx.disposables.add` | none | **A1** | -| `ctx.asUser(userId, fn)` | none, always audited | **A1** | -| `ctx.currentActor()` | none | **A1** | -| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | -| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** | -| `ctx.sync.registerEntity` | none | **A3** | -| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | -| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | -| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4** | -| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | -| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | -| `ctx.notify.*` | `notify:send` | A6 | -| `ctx.auth.*` | `auth:provide` | **A8** | -| `ctx.fetch` | `network:outbound` | B | +| Member | Capability | Status | +| ------------------------------------------------ | ------------------------------------ | ----------------------- | +| `ctx.pluginId`, `ctx.manifest` | none | **A1** | +| `ctx.log.*` | none | **A1** | +| `ctx.events.emit` / `.on` | `events:core` for core topics | **A1** | +| `ctx.kv.get/set/delete/list` | `kv:own` | **A1** | +| `ctx.registry.*` | none | **A1** | +| `ctx.services.provide` / `.get` | per-service RBAC | **A1** | +| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** | +| `ctx.disposables.add` | none | **A1** | +| `ctx.asUser(userId, fn)` | none, always audited | **A1** | +| `ctx.currentActor()` | none | **A1** | +| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** | +| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** | +| `ctx.sync.registerEntity` | none | **A3** | +| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | +| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | +| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** | +| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | +| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | +| `ctx.notify.*` | `notify:send` | A6 | +| `ctx.auth.*` | `auth:provide` | **A8** | +| `ctx.fetch` | `network:outbound` | B | `ctx.ssh` was pulled forward from B so plugin transports go through core's 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) 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 Never comes from plugin code. It is held in `AsyncLocalStorage` and set two diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index a734aac80..0d9363d90 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -23,3 +23,20 @@ it's done. 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 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. diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 84cd108e8..e57900b2e 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -409,6 +409,56 @@ export interface PluginHostSummary { 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 +> & { + 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 +>; + /** The same shape canAccessHost returns internally, without secrets. */ export interface PluginHostAccess { hasAccess: boolean; @@ -462,6 +512,30 @@ export interface PluginHosts { hostId: number, level: PluginHostShareLevel, ) => Promise; + /** + * 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; + /** + * 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; + /** + * 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; /** * 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. diff --git a/packages/plugin-sdk/src/db.ts b/packages/plugin-sdk/src/db.ts index afef5b1e0..4b9d8db54 100644 --- a/packages/plugin-sdk/src/db.ts +++ b/packages/plugin-sdk/src/db.ts @@ -20,6 +20,7 @@ export type PluginColumnType = | "varchar" | "integer" | "bigint" + | "real" | "boolean" | "timestamp" | "json" @@ -77,6 +78,8 @@ export const varchar = (length: number = KEY_LENGTH) => export const integer = () => builder({ type: "integer" }); 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" }); /** Stored as text on every engine. See src/backend/database/repositories/sql-timestamp.ts. */ diff --git a/packages/plugin-sdk/src/ddl.ts b/packages/plugin-sdk/src/ddl.ts index 356c49f0a..8a93f55a7 100644 --- a/packages/plugin-sdk/src/ddl.ts +++ b/packages/plugin-sdk/src/ddl.ts @@ -62,6 +62,9 @@ function columnType( return dialect === "mysql" ? "int" : "integer"; case "bigint": return dialect === "sqlite" ? "integer" : "bigint"; + case "real": + if (dialect === "sqlite") return "real"; + return dialect === "mysql" ? "double" : "double precision"; case "boolean": return dialect === "sqlite" ? "integer" : "boolean"; case "varchar": diff --git a/packages/plugin-sdk/src/table-builder.ts b/packages/plugin-sdk/src/table-builder.ts index 48b718a3d..d89272db0 100644 --- a/packages/plugin-sdk/src/table-builder.ts +++ b/packages/plugin-sdk/src/table-builder.ts @@ -12,6 +12,7 @@ import { sqliteTable, text as sqliteText, integer as sqliteInteger, + real as sqliteReal, index as sqliteIndex, uniqueIndex as sqliteUniqueIndex, type AnySQLiteColumn, @@ -55,6 +56,9 @@ export function buildTable( case "refHost": built = sqliteInteger(name); break; + case "real": + built = sqliteReal(name); + break; case "boolean": built = sqliteInteger(name, { mode: "boolean" }); break; diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index ff721abf4..cd7e48d7f 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -24,6 +24,9 @@ import type { PluginSshAuthProvider, PluginSshHost, PluginHostSummary, + PluginHostRecord, + PluginHostCreateInput, + PluginHostUpdateInput, PluginHostAccess, PluginHostShareResult, PluginShareableUser, @@ -174,6 +177,8 @@ export function createFakeContext( const hostsById = new Map( (options.hosts ?? []).map((h) => [h.id, h]), ); + const hostRecordsById = new Map(); + let nextHostId = Math.max(0, ...(options.hosts ?? []).map((h) => h.id)) + 1; const services = new Map(); const auth: FakeAuthRegistrations = { sshAuthProviders: [], @@ -421,6 +426,33 @@ export function createFakeContext( permissionLevel: "manage", }; }, + create: async ( + host: PluginHostCreateInput, + ): Promise => { + 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 => { + const existing = hostRecordsById.get(hostId); + if (!existing) return null; + const updated = { ...existing, ...patch }; + hostRecordsById.set(hostId, updated); + return updated; + }, + listOwned: async (): Promise => [ + ...hostRecordsById.values(), + ], share: async ( hostId, targets, @@ -706,6 +738,18 @@ export function createMockCtx( require("hosts:read"); 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) => { require("hosts:write"); return ctx.hosts.share(hostId, targets, permissionLevel, durationHours); diff --git a/plugins/host-metrics/manifest.json b/plugins/host-metrics/manifest.json index ff6d6e1d5..378677b13 100644 --- a/plugins/host-metrics/manifest.json +++ b/plugins/host-metrics/manifest.json @@ -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": { diff --git a/plugins/host-metrics/src/backend/routes.ts b/plugins/host-metrics/src/backend/routes.ts index d98945403..44422d5fc 100644 --- a/plugins/host-metrics/src/backend/routes.ts +++ b/plugins/host-metrics/src/backend/routes.ts @@ -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( ); } -const proxmoxPollingManager = new ProxmoxPollingManager( - { - 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. diff --git a/plugins/host-metrics/src/frontend/index.tsx b/plugins/host-metrics/src/frontend/index.tsx index 13090eea8..4055d3c05 100644 --- a/plugins/host-metrics/src/frontend/index.tsx +++ b/plugins/host-metrics/src/frontend/index.tsx @@ -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[0]["setField"]; @@ -31,26 +28,10 @@ function MetricsTab({ sshHost, label, isVisible }: TabProps) { ); } -function ProxmoxTab({ sshHost, label, isVisible }: TabProps) { - return ( - - ); -} - function MetricsStandalone({ hostId }: StandaloneViewProps) { return ; } -function ProxmoxStandalone({ hostId }: StandaloneViewProps) { - return ; -} - 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 ( - - ); -} - 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 - >, - }); - app.registerHomepageWidget( metricsChartWidget as unknown as HomepageWidgetContribution, ); diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/GuestTable.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/GuestTable.tsx deleted file mode 100644 index 23ea3cd6f..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/GuestTable.tsx +++ /dev/null @@ -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 -; - } - 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 ( -
-
- - {clamped.toFixed(0)}% - - {usedGiB != null && totalGiB != null && ( - - {usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G - - )} -
-
-
-
-
- ); -} - -export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) { - const { t } = useTranslation(); - const [query, setQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("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 ( -
-
- - {t("proxmoxStats.guestsSummary")} - - - {t("proxmoxStats.guestCounts", { running, total: guests.length })} - -
-
- - setQuery(e.target.value)} - placeholder={t("proxmoxStats.searchGuests")} - className="h-7 w-40 pl-6 text-xs" - /> -
-
- {(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => ( - - ))} -
-
- {(["all", "running", "stopped"] as StatusFilter[]).map((f) => ( - - ))} -
-
-
- -
- {guests.length === 0 ? ( -
- {t("proxmoxStats.noGuests")} -
- ) : filtered.length === 0 ? ( -
- {t("proxmoxStats.noGuestsMatch")} -
- ) : ( - - - - - - - - - - - - - - {filtered.map((guest) => ( - - - - - - - - - - ))} - -
- {t("proxmoxStats.name")} - - {t("proxmoxStats.status")} - ID - {t("proxmoxStats.cpu")} - - {t("proxmoxStats.mem")} - - {t("proxmoxStats.disk")} - - {t("proxmoxStats.uptime")} -
-
- {guest.type === "lxc" ? ( - - ) : ( - - )} - - {guest.name} - - - {guest.type === "lxc" ? "LXC" : "VM"} - -
-
-
- - - {guest.status === "running" - ? t("proxmoxStats.running") - : t("proxmoxStats.stopped")} - -
-
- {guest.vmid} - - - - - - - - {formatUptime(guest.uptimeSeconds)} -
- )} -
-
- ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/HostProxmoxStatsTab.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/HostProxmoxStatsTab.tsx deleted file mode 100644 index e923af753..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/HostProxmoxStatsTab.tsx +++ /dev/null @@ -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 = ( - key: K, - value: HostEditorForm[K], -) => void; - -export function HostProxmoxStatsTab({ - form, - setField, -}: { - form: HostEditorForm; - setField: SetHostField; -}) { - const { t } = useTranslation(); - - return ( - } - > -
- - setField("enableProxmoxStats", v)} - /> - - {form.enableProxmoxStats && ( - <> - - - setField("proxmoxStatsConfig", { - ...form.proxmoxStatsConfig, - pollInterval: Math.max( - 15, - Number.parseInt(e.target.value || "60", 10), - ), - }) - } - /> - - - - setField("proxmoxStatsConfig", { - ...form.proxmoxStatsConfig, - nodeName: e.target.value || null, - }) - } - /> - - - )} -
-
- ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/NodeSummaryStrip.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/NodeSummaryStrip.tsx deleted file mode 100644 index b04f82c51..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/NodeSummaryStrip.tsx +++ /dev/null @@ -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 ( -
-
- {icon} - - {label} - -
-
- - {clamped === null ? "N/A" : `${clamped.toFixed(0)}%`} - - {detail && ( - - {detail} - - )} -
-
-
-
- {sparkline && sparkline.length > 1 && } -
- ); -} - -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 ( - - - - ); -} - -export function NodeSummaryStrip({ - node, - histories, -}: { - node: ProxmoxNodeStats | undefined; - histories: { cpu: number[]; memory: number[]; disk: number[] }; -}) { - const { t } = useTranslation(); - - return ( -
- } - 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} - /> - } - 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} - /> - } - 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} - /> -
-
- - - {t("proxmoxStats.uptime")} - -
- - {node?.uptime.formatted ?? "N/A"} - -
- - - {node?.system.hostname ?? "-"} - {node?.system.pveVersion ? ` ยท ${node.system.pveVersion}` : ""} - -
-
-
- ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsApp.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsApp.tsx deleted file mode 100644 index 36805316a..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsApp.tsx +++ /dev/null @@ -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 = ({ hostId }) => { - const { t } = useTranslation(); - return ( - - {(hostConfig, phase) => { - if (phase === "loading") { - return ( -
- -
- ); - } - - if (!hostConfig) { - return ( -
- -
- ); - } - - return ( - - ); - }} -
- ); -}; - -export default ProxmoxStatsApp; diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsTab.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsTab.tsx deleted file mode 100644 index e46e9b790..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/ProxmoxStatsTab.tsx +++ /dev/null @@ -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> & 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 ( - - - - ); -} - -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( - null, - ); - const [histories, setHistories] = React.useState({ - 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( - 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 => { - 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 ( -
-
- {retry.status === "connected" && !notEnabled && ( -
-
-
- -
-

{title}

-
- -
- )} - - {showContent && ( -
-
- -
- - - -
- - - {snapshot.cluster.clustered && ( - - )} -
-
- )} - - - -

- {t("proxmoxStats.noHostSelected")} -

-
- ) : undefined - } - /> -
-
- ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/cards/ClusterHealthCard.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/cards/ClusterHealthCard.tsx deleted file mode 100644 index a480ec4f3..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/cards/ClusterHealthCard.tsx +++ /dev/null @@ -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 ( - } - scroll - > - {!cluster || !cluster.clustered ? ( - - {t("proxmoxStats.notClustered")} - - ) : ( -
-
- - - {cluster.clusterName ?? t("proxmoxStats.clusterHealth")} - - - {cluster.quorate - ? t("proxmoxStats.quorate") - : t("proxmoxStats.notQuorate")} - -
-
- {cluster.nodes.map((node) => ( -
- - - {node.name} - - - {node.online - ? t("proxmoxStats.nodeOnline") - : t("proxmoxStats.nodeOffline")} - -
- ))} -
-
- )} -
- ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/cards/NodeNetworkCard.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/cards/NodeNetworkCard.tsx deleted file mode 100644 index 0b4a590a8..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/cards/NodeNetworkCard.tsx +++ /dev/null @@ -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 ( - } - scroll - > - {interfaces.length === 0 ? ( - N/A - ) : ( -
- {interfaces.map((iface) => ( - - ))} -
- )} -
- ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx b/plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx deleted file mode 100644 index c79356014..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/cards/StoragePoolsCard.tsx +++ /dev/null @@ -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 ( - } - scroll - > - {pools.length === 0 ? ( - N/A - ) : ( -
- {pools.map((pool) => ( -
-
- - {pool.name} - - - {pool.type} - - {!pool.active && ( - - {t("proxmoxStats.inactive")} - - )} -
-
-
-
-
- - {pool.usedGiB !== null && pool.totalGiB !== null - ? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB` - : "N/A"} - -
-
- ))} -
- )} - - ); -} diff --git a/plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts b/plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts deleted file mode 100644 index 0b45bbdaf..000000000 --- a/plugins/host-metrics/src/frontend/proxmox-stats/proxmox-stats-api.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - const res = await statsApi.get(`/proxmox-stats/history/${hostId}`, { - params: opts, - }); - return res.data as ProxmoxStatsHistoryResponse; -} diff --git a/scripts/plugin-boundary-allowlist.json b/scripts/plugin-boundary-allowlist.json index 58c955d43..aa0fa1862 100644 --- a/scripts/plugin-boundary-allowlist.json +++ b/scripts/plugin-boundary-allowlist.json @@ -66,15 +66,6 @@ "plugins/host-metrics/src/frontend/host-metrics-api.ts", "plugins/host-metrics/src/frontend/index.tsx", "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/proxmox/src/frontend/HostProxmoxTab.tsx", "plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx",