diff --git a/internal/site/src/components/navbar.tsx b/internal/site/src/components/navbar.tsx index 73db27e3..fdcb9e09 100644 --- a/internal/site/src/components/navbar.tsx +++ b/internal/site/src/components/navbar.tsx @@ -1,4 +1,5 @@ import { Trans } from "@lingui/react/macro" +import { t } from "@lingui/core/macro" import { getPagePath } from "@nanostores/router" import { ContainerIcon, @@ -31,6 +32,7 @@ import { Logo } from "./logo" import { ModeToggle } from "./mode-toggle" import { $router, basePath, Link, prependBasePath } from "./router" import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip" +import { ProxmoxIcon } from "./ui/icons" const CommandPalette = lazy(() => import("./command-palette")) @@ -77,6 +79,20 @@ export default function Navbar() { S.M.A.R.T. + + + + + + + + Proxmox + + diff --git a/internal/site/src/components/pve-table/pve-table-columns.tsx b/internal/site/src/components/pve-table/pve-table-columns.tsx new file mode 100644 index 00000000..598e942f --- /dev/null +++ b/internal/site/src/components/pve-table/pve-table-columns.tsx @@ -0,0 +1,170 @@ +import type { Column, ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { cn, decimalString, formatBytes, hourWithSeconds, toFixedFloat } from "@/lib/utils" +import type { PveVmRecord } from "@/types" +import { + ArrowUpDownIcon, + ClockIcon, + CpuIcon, + MemoryStickIcon, + MonitorIcon, + ServerIcon, + TagIcon, + TimerIcon, +} from "lucide-react" +import { EthernetIcon } from "../ui/icons" +import { Badge } from "../ui/badge" +import { t } from "@lingui/core/macro" +import { $allSystemsById } from "@/lib/stores" +import { useStore } from "@nanostores/react" + +/** Format uptime in seconds to a human-readable string */ +export function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s` + if (seconds < 3600) { + const m = Math.floor(seconds / 60) + return `${m}m` + } + if (seconds < 86400) { + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + return m > 0 ? `${h}h ${m}m` : `${h}h` + } + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + return h > 0 ? `${d}d ${h}h` : `${d}d` +} + +export const pveVmCols: ColumnDef[] = [ + { + id: "name", + sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), + accessorFn: (record) => record.name, + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + { + id: "system", + accessorFn: (record) => record.system, + sortingFn: (a, b) => { + const allSystems = $allSystemsById.get() + const systemNameA = allSystems[a.original.system]?.name ?? "" + const systemNameB = allSystems[b.original.system]?.name ?? "" + return systemNameA.localeCompare(systemNameB) + }, + header: ({ column }) => , + cell: ({ getValue }) => { + const allSystems = useStore($allSystemsById) + return {allSystems[getValue() as string]?.name ?? ""} + }, + }, + { + id: "type", + accessorFn: (record) => record.type, + sortingFn: (a, b) => a.original.type.localeCompare(b.original.type), + header: ({ column }) => , + cell: ({ getValue }) => { + const type = getValue() as string + return ( + + {type} + + ) + }, + }, + { + id: "cpu", + accessorFn: (record) => record.cpu, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + return {`${decimalString(val, val >= 10 ? 1 : 2)}%`} + }, + }, + { + id: "mem", + accessorFn: (record) => record.mem, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + const formatted = formatBytes(val, false, undefined, true) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "net", + accessorFn: (record) => record.net, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + const formatted = formatBytes(val, true, undefined, false) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "maxcpu", + accessorFn: (record) => record.maxcpu, + header: ({ column }) => , + invertSorting: true, + cell: ({ getValue }) => { + return {getValue() as number} + }, + }, + { + id: "maxmem", + accessorFn: (record) => record.maxmem, + header: ({ column }) => , + invertSorting: true, + cell: ({ getValue }) => { + // maxmem is stored in bytes; convert to MB for formatBytes + const formatted = formatBytes(getValue() as number, false, undefined, false) + return {`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`} + }, + }, + { + id: "uptime", + accessorFn: (record) => record.uptime, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + return {formatUptime(getValue() as number)} + }, + }, + { + id: "updated", + invertSorting: true, + accessorFn: (record) => record.updated, + header: ({ column }) => , + cell: ({ getValue }) => { + const timestamp = getValue() as number + return {hourWithSeconds(new Date(timestamp).toISOString())} + }, + }, +] + +function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { + const isSorted = column.getIsSorted() + return ( + + ) +} diff --git a/internal/site/src/components/pve-table/pve-table.tsx b/internal/site/src/components/pve-table/pve-table.tsx new file mode 100644 index 00000000..3c82c88f --- /dev/null +++ b/internal/site/src/components/pve-table/pve-table.tsx @@ -0,0 +1,368 @@ +import { t } from "@lingui/core/macro" +import { Trans } from "@lingui/react/macro" +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type Row, + type SortingState, + type Table as TableType, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" +import { memo, RefObject, useEffect, useRef, useState } from "react" +import { Input } from "@/components/ui/input" +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { pb } from "@/lib/api" +import type { PveVmRecord } from "@/types" +import { pveVmCols, formatUptime } from "@/components/pve-table/pve-table-columns" +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils" +import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet" +import { $allSystemsById } from "@/lib/stores" +import { LoaderCircleIcon, XIcon } from "lucide-react" +import { Separator } from "../ui/separator" +import { $router, Link } from "../router" +import { listenKeys } from "nanostores" +import { getPagePath } from "@nanostores/router" + +export default function PveTable({ systemId }: { systemId?: string }) { + const loadTime = Date.now() + const [data, setData] = useState(undefined) + const [sorting, setSorting] = useBrowserStorage( + `sort-pve-${systemId ? 1 : 0}`, + [{ id: systemId ? "name" : "system", desc: false }], + sessionStorage + ) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [rowSelection, setRowSelection] = useState({}) + const [globalFilter, setGlobalFilter] = useState("") + + useEffect(() => { + function fetchData(systemId?: string) { + pb.collection("pve_vms") + .getList(0, 2000, { + fields: "id,name,type,cpu,mem,net,maxcpu,maxmem,uptime,system,updated", + filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, + }) + .then(({ items }) => { + if (items.length === 0) { + setData((curItems) => { + if (systemId) { + return curItems?.filter((item) => item.system !== systemId) ?? [] + } + return [] + }) + return + } + setData((curItems) => { + const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) + const vmIds = new Set() + const newItems: PveVmRecord[] = [] + for (const item of items) { + if (Math.abs(lastUpdated - item.updated) < 70_000) { + vmIds.add(item.id) + newItems.push(item) + } + } + for (const item of curItems ?? []) { + if (!vmIds.has(item.id) && lastUpdated - item.updated < 70_000) { + newItems.push(item) + } + } + return newItems + }) + }) + } + + // initial load + fetchData(systemId) + + // if no systemId, pull pve vms after every system update + if (!systemId) { + return $allSystemsById.listen((_value, _oldValue, systemId) => { + // exclude initial load of systems + if (Date.now() - loadTime > 500) { + fetchData(systemId) + } + }) + } + + // if systemId, fetch pve vms after the system is updated + return listenKeys($allSystemsById, [systemId], (_newSystems) => { + fetchData(systemId) + }) + }, []) + + const table = useReactTable({ + data: data ?? [], + columns: pveVmCols.filter((col) => (systemId ? col.id !== "system" : true)), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + defaultColumn: { + sortUndefined: "last", + size: 100, + minSize: 0, + }, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue) => { + const vm = row.original + const systemName = $allSystemsById.get()[vm.system]?.name ?? "" + const id = vm.id ?? "" + const name = vm.name ?? "" + const type = vm.type ?? "" + const searchString = `${systemName} ${id} ${name} ${type}`.toLowerCase() + + return (filterValue as string) + .toLowerCase() + .split(" ") + .every((term) => searchString.includes(term)) + }, + }) + + const rows = table.getRowModel().rows + const visibleColumns = table.getVisibleLeafColumns() + + return ( + + +
+
+ + All Proxmox VMs + + + CPU is percent of overall host CPU usage. + +
+
+ setGlobalFilter(e.target.value)} + className="ps-4 pe-10 w-full" + /> + {globalFilter && ( + + )} +
+
+
+
+ +
+
+ ) +} + +const AllPveTable = memo(function AllPveTable({ + table, + rows, + colLength, + data, +}: { + table: TableType + rows: Row[] + colLength: number + data: PveVmRecord[] | undefined +}) { + const scrollRef = useRef(null) + const activeVm = useRef(null) + const [sheetOpen, setSheetOpen] = useState(false) + const openSheet = (vm: PveVmRecord) => { + activeVm.current = vm + setSheetOpen(true) + } + + const virtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 54, + getScrollElement: () => scrollRef.current, + overscan: 5, + }) + const virtualRows = virtualizer.getVirtualItems() + + const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) + const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) + + return ( +
2) && "min-h-50" + )} + ref={scrollRef} + > + {/* add header height to table size */} +
+ + + + {rows.length ? ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + return + }) + ) : ( + + + {data ? ( + No results. + ) : ( + + )} + + + )} + +
+
+ +
+ ) +}) + +function PveVmSheet({ + sheetOpen, + setSheetOpen, + activeVm, +}: { + sheetOpen: boolean + setSheetOpen: (open: boolean) => void + activeVm: RefObject +}) { + const vm = activeVm.current + if (!vm) return null + + const memFormatted = formatBytes(vm.mem, false, undefined, true) + const maxMemFormatted = formatBytes(vm.maxmem, false, undefined, false) + const netFormatted = formatBytes(vm.net, true, undefined, false) + + return ( + + + + {vm.name} + + + {$allSystemsById.get()[vm.system]?.name ?? ""} + + + {vm.type} + + Up {formatUptime(vm.uptime)} + + {vm.id} + + +
+

+ Details +

+
+
+ CPU Usage +
+
{`${decimalString(vm.cpu, vm.cpu >= 10 ? 1 : 2)}%`}
+ +
+ Memory Used +
+
{`${decimalString(memFormatted.value, memFormatted.value >= 10 ? 1 : 2)} ${memFormatted.unit}`}
+ +
+ Network +
+
{`${decimalString(netFormatted.value, netFormatted.value >= 10 ? 1 : 2)} ${netFormatted.unit}`}
+ +
+ vCPUs +
+
{vm.maxcpu}
+ +
+ Max Memory +
+
{`${decimalString(maxMemFormatted.value, maxMemFormatted.value >= 10 ? 1 : 2)} ${maxMemFormatted.unit}`}
+ +
+ Uptime +
+
{formatUptime(vm.uptime)}
+
+
+
+
+ ) +} + +function PveTableHead({ table }: { table: TableType }) { + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + ) +} + +const PveTableRow = memo(function PveTableRow({ + row, + virtualRow, + openSheet, +}: { + row: Row + virtualRow: VirtualItem + openSheet: (vm: PveVmRecord) => void +}) { + return ( + openSheet(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +}) diff --git a/internal/site/src/components/router.tsx b/internal/site/src/components/router.tsx index fb525972..079b24b8 100644 --- a/internal/site/src/components/router.tsx +++ b/internal/site/src/components/router.tsx @@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router" const routes = { home: "/", containers: "/containers", + proxmox: "/proxmox", smart: "/smart", system: `/system/:id`, settings: `/settings/:name?`, diff --git a/internal/site/src/components/routes/proxmox.tsx b/internal/site/src/components/routes/proxmox.tsx new file mode 100644 index 00000000..e21d6fb8 --- /dev/null +++ b/internal/site/src/components/routes/proxmox.tsx @@ -0,0 +1,26 @@ +import { useLingui } from "@lingui/react/macro" +import { memo, useEffect, useMemo } from "react" +import PveTable from "@/components/pve-table/pve-table" +import { ActiveAlerts } from "@/components/active-alerts" +import { FooterRepoLink } from "@/components/footer-repo-link" + +export default memo(() => { + const { t } = useLingui() + + useEffect(() => { + document.title = `${t`All Proxmox VMs`} / Beszel` + }, [t]) + + return useMemo( + () => ( + <> +
+ + +
+ + + ), + [] + ) +}) diff --git a/internal/site/src/components/ui/icons.tsx b/internal/site/src/components/ui/icons.tsx index b323ab83..bee56c63 100644 --- a/internal/site/src/components/ui/icons.tsx +++ b/internal/site/src/components/ui/icons.tsx @@ -185,3 +185,12 @@ export function PlugChargingIcon(props: SVGProps) { ) } + +// simple-icons (CC0) https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md +export function ProxmoxIcon(props: SVGProps) { + return ( + + + + ) +} diff --git a/internal/site/src/main.tsx b/internal/site/src/main.tsx index 4688f38e..3bd188e5 100644 --- a/internal/site/src/main.tsx +++ b/internal/site/src/main.tsx @@ -20,6 +20,7 @@ import * as systemsManager from "@/lib/systemsManager.ts" const LoginPage = lazy(() => import("@/components/login/login.tsx")) const Home = lazy(() => import("@/components/routes/home.tsx")) const Containers = lazy(() => import("@/components/routes/containers.tsx")) +const Proxmox = lazy(() => import("@/components/routes/proxmox.tsx")) const Smart = lazy(() => import("@/components/routes/smart.tsx")) const SystemDetail = lazy(() => import("@/components/routes/system.tsx")) const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx")) @@ -63,6 +64,8 @@ const App = memo(() => { return } else if (page.route === "containers") { return + } else if (page.route === "proxmox") { + return } else if (page.route === "smart") { return } else if (page.route === "settings") { diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 53dce80c..94389af8 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -275,6 +275,28 @@ export interface ContainerRecord extends RecordModel { updated: number } +export interface PveVmRecord extends RecordModel { + id: string + system: string + name: string + /** "qemu" or "lxc" */ + type: string + /** CPU usage percent (0–100, relative to host) */ + cpu: number + /** Memory used (MB) */ + mem: number + /** Network bandwidth (bytes/s, combined send+recv) */ + net: number + /** Max vCPU count */ + maxcpu: number + /** Max memory (bytes) */ + maxmem: number + /** Uptime (seconds) */ + uptime: number + /** Unix timestamp (ms) */ + updated: number +} + export type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d" export interface ChartTimeData { diff --git a/pve-plan.md b/pve-plan.md deleted file mode 100644 index 01af64d8..00000000 --- a/pve-plan.md +++ /dev/null @@ -1,133 +0,0 @@ -# Goal - -Add Proxmox VE (PVE) stats for VMs and LXCs to Beszel, working similarly to how Docker containers are tracked: collect every minute, show stacked area graphs on the system page for CPU, mem, network, plus a dedicated global table/page showing all VMs/LXCs with current data. - -# Instructions - -- Reuse container.Stats struct for PVE data transport and storage (compatible with existing averaging functions after minor refactor) -- Store clean VM name (without type) in container.Stats.Name; store Proxmox resource ID (e.g. "qemu/100") in container.Stats.Id field; store type ("qemu" or "lxc") in container.Stats.Image field — both Id and Image are json:"-" / CBOR-only so they don't pollute stored JSON stats -- Add PROXMOX_INSECURE_TLS env var (default true) for TLS verification control -- Filter to only running VMs/LXCs (check resource.Status == "running") -- pve_vms table row ID: use makeStableHashId(systemId, resourceId) — same FNV-32a hash used for systemd services, returns 8 hex chars -- VM type (qemu/lxc) stripped from name, shown as a separate badge column in the PVE table -- PVE page is global (shows VMs/LXCs across all systems, with a System column) — mirrors the containers page -- pve_stats collection structure is identical to container_stats (same fields: id, system, stats JSON, type select, created, updated) -- pve_vms collection needs: id (8-char hex), system (relation), name (text), type (text: "qemu"/"lxc"), cpu (number), memory (number), net (number), updated (number) - Discoveries -- container.Stats struct has Id, Image, Status fields tagged json:"-" but transmitted via CBOR (keys 7, 8, 6 respectively) — perfect for carrying PVE-specific metadata without polluting stored JSON. Id is cbor key 7, Image is cbor key 8. -- makeStableHashId(...strings) already exists in internal/hub/systems/system.go using FNV-32a, returns 8-char hex — reuse this for pve_vms row IDs -- AverageContainerStats() in records.go hardcodes "container_stats" table name — needs to be refactored to accept a collectionName parameter to be reused for pve_stats -- deleteOldSystemStats() in records.go hardcodes [2]string{"system_stats", "container_stats"} — needs pve_stats added -- ContainerChart component reads chartData.containerData directly from the chartData prop — it cannot be reused for PVE without modification. For PVE charts, we need to either modify ContainerChart to accept a custom data prop, or create a PveChart wrapper that passes PVE-shaped chartData. The simplest approach: pass a modified chartData object with containerData replaced by pveData to a reused ContainerChart. -- useContainerChartConfigs(containerData) in hooks.ts is pure and can be called with any ChartData["containerData"]-shaped array — perfectly reusable for PVE. -- $containerFilter atom is in internal/site/src/lib/stores.ts — need to add $pveFilter atom similarly -- ChartData interface in types.d.ts needs a pveData field added alongside containerData -- The getStats() function in system.tsx is generic and reusable for fetching pve_stats -- go-proxmox v0.4.0 is already a direct dependency in go.mod -- Migration files are in internal/migrations/ — only 2 files exist currently; new migration should be named sequentially (e.g., 1_pve_collections.go) -- The ContainerChart component reads const { containerData } = chartData on line 37. The fix is to pass a synthetic chartData that has containerData replaced by pveData when rendering PVE charts. -- createContainerRecords() in system.go checks data.Containers[0].Id != "" before saving — PVE stats use .Id to store resource ID (e.g. "qemu/100"), so that check will work correctly for PVE too -- AverageContainerStats in records.go uses stat.Name as the key for sums map — PVE data stored with clean name (no type suffix) works correctly -- The containers table collection ID is "pbc_1864144027" — need unique IDs for new collections in the migration -- The container_stats collection ID is "juohu4jipgc13v7" — need unique IDs for pve_stats and pve_vms -- The containers table id field pattern is [a-f0-9]{6} min 6, max 12 — for pve_vms we want 8-char hex IDs: set pattern [a-f0-9]{8}, min 8, max 8 -- In the migration file, use app.ImportCollectionsByMarshaledJSON with false for upsert (same as existing migration) -- CombinedData struct in system.go uses cbor keys 0-4 for existing fields — PVEStats should be cbor key 5 -- For pve\*stats in CreateLongerRecords(), need to add it to the collections array and handle it similarly to container_stats - Accomplished - Planning and file reading phase complete. No files have been written/modified yet. - All relevant source files have been read and fully understood. The complete implementation plan is finalized. Ready to begin writing code. - Files to CREATE - - | File | Purpose | - | ------------------------------------------------------------ | -------------------------------------------------------------------- | - | internal/migrations/1_pve_collections.go | New pve_stats + pve_vms DB collections | - | internal/site/src/components/routes/pve.tsx | Global PVE VMs/LXCs page route | - | internal/site/src/components/pve-table/pve-table.tsx | PVE table component (mirrors containers-table) | - | internal/site/src/components/pve-table/pve-table-columns.tsx | Table column defs (Name, System, Type badge, CPU, Mem, Net, Updated) | - - Files to MODIFY - - | File | Changes needed | - |---|---| - | agent/pve.go | Refactor: clean name (no type suffix), store resourceId in .Id, type in .Image, filter running-only, add PROXMOX*INSECURE_TLS env var, change network tracking to use bytes | - | agent/agent.go | Add pveManager *pveManager field to Agent struct; initialize in NewAgent(); call getPVEStats() in gatherStats(), store in data.PVEStats | - | internal/entities/system/system.go | Add PVEStats []*container.Stats \json:"pve,omitempty" cbor:"5,keyasint,omitempty"\` to CombinedData` struct | - | internal/hub/systems/system.go | In createRecords(): if data.PVEStats non-empty, save pve_stats record + call new createPVEVMRecords() function | - | internal/records/records.go | Refactor AverageContainerStats(db, records) → AverageContainerStats(db, records, collectionName); add pve_stats to CreateLongerRecords() and deleteOldSystemStats(); add deleteOldPVEVMRecords() called from DeleteOldRecords() | - | internal/site/src/components/routes/system.tsx | Add pveData state + fetching (reuse getStats() for pve_stats); add usePVEChartConfigs; add 3 PVE ChartCard+ContainerChart blocks (CPU/Mem/Net) using synthetic chartData with pveData as containerData; add PVE filter bar; reset $pveFilter on unmount | - | internal/site/src/components/router.tsx | Add pve: "/pve" route | - | internal/site/src/components/navbar.tsx | Add PVE nav link (using ServerIcon or BoxesIcon) between Containers and SMART links | - | internal/site/src/lib/stores.ts | Add export const $pveFilter = atom("") | - | internal/site/src/types.d.ts | Add PveStatsRecord interface (same shape as ContainerStatsRecord); add PveVMRecord interface; add pveData to ChartData interface | - Key Implementation Notes - agent/pve.go - // Filter running only: skip if resource.Status != "running" - // Store type without type in name: resourceStats.Name = resource.Name - // Store resource ID: resourceStats.Id = resource.ID (e.g. "qemu/100") - // Store type: resourceStats.Image = resource.Type (e.g. "qemu" or "lxc") - // PROXMOX_INSECURE_TLS: default true, parse "false" to disable - insecureTLS := true - if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists { - insecureTLS = val != "false" - } - internal/hub/systems/system.go — createPVEVMRecords - func createPVEVMRecords(app core.App, data []\*container.Stats, systemId string) error { - params := dbx.Params{"system": systemId, "updated": time.Now().UTC().UnixMilli()} - valueStrings := make([]string, 0, len(data)) - for i, vm := range data { - suffix := fmt.Sprintf("%d", i) - valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:type%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix)) - params["id"+suffix] = makeStableHashId(systemId, vm.Id) - params["name"+suffix] = vm.Name - params["type"+suffix] = vm.Image // "qemu" or "lxc" - params["cpu"+suffix] = vm.Cpu - params["memory"+suffix] = vm.Mem - netBytes := vm.Bandwidth[0] + vm.Bandwidth[1] - if netBytes == 0 { - netBytes = uint64((vm.NetworkSent + vm.NetworkRecv) * 1024 \* 1024) - } - params["net"+suffix] = netBytes - } - queryString := fmt.Sprintf( - "INSERT INTO pve*vms (id, system, name, type, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, memory=excluded.memory, net=excluded.net, updated=excluded.updated", - strings.Join(valueStrings, ","), - ) - *, err := app.DB().NewQuery(queryString).Bind(params).Execute() - return err - } - system.tsx — PVE chart rendering pattern - // Pass synthetic chartData to ContainerChart so it reads pveData as containerData - const pveSyntheticChartData = useMemo(() => ({...chartData, containerData: pveData}), [chartData, pveData]) - // Then: - - - records.go — AverageContainerStats refactor - // Change signature to accept collectionName: - func (rm \*RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds, collectionName string) []container.Stats - // Change hardcoded query string: - db.NewQuery(fmt.Sprintf("SELECT stats FROM %s WHERE id = {:id}", collectionName)).Bind(queryParams).One(&statsRecord) - // In CreateLongerRecords, update all callers: - longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, collection.Name)) - Relevant files / directories - agent/pve.go # Main file to refactor - agent/agent.go # Add pveManager field + initialization - internal/entities/system/system.go # Add PVEStats to CombinedData - internal/entities/container/container.go # container.Stats struct (reused for PVE) - internal/hub/systems/system.go # Hub record creation (createRecords, makeStableHashId) - internal/records/records.go # Longer records + deletion - internal/migrations/0_collections_snapshot_0_18_0_dev_2.go # Reference for collection schema format - internal/migrations/1_pve_collections.go # TO CREATE - internal/site/src/ # Frontend root - internal/site/src/types.d.ts # TypeScript types - internal/site/src/lib/stores.ts # Nanostores atoms - internal/site/src/components/router.tsx # Routes - internal/site/src/components/navbar.tsx # Navigation - internal/site/src/components/routes/system.tsx # System detail page (1074 lines) - add PVE charts - internal/site/src/components/routes/containers.tsx # Reference for pve.tsx structure - internal/site/src/components/routes/pve.tsx # TO CREATE - internal/site/src/components/charts/container-chart.tsx # Reused for PVE charts (reads chartData.containerData) - internal/site/src/components/charts/hooks.ts # useContainerChartConfigs (reused for PVE) - internal/site/src/components/containers-table/containers-table.tsx # Reference for PVE table - internal/site/src/components/containers-table/containers-table-columns.tsx # Reference for PVE columns - internal/site/src/components/pve-table/ # TO CREATE (directory + 2 files)