mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-21 21:26:16 +01:00
updates
This commit is contained in:
@@ -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() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "proxmox")}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label={t`Proxmox`}
|
||||
>
|
||||
<ProxmoxIcon className="h-[1.2rem] w-[1.2rem] opacity-90" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Proxmox</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Tooltip>
|
||||
|
||||
170
internal/site/src/components/pve-table/pve-table-columns.tsx
Normal file
170
internal/site/src/components/pve-table/pve-table-columns.tsx
Normal file
@@ -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<PveVmRecord>[] = [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
accessorFn: (record) => record.name,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={MonitorIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 xl:w-48 block truncate">{getValue() as string}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
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 }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorFn: (record) => record.type,
|
||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={TagIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue() as string
|
||||
return (
|
||||
<Badge variant="outline" className="dark:border-white/12 ms-1.5">
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorFn: (record) => record.cpu,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mem",
|
||||
accessorFn: (record) => record.mem,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, true)
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "net",
|
||||
accessorFn: (record) => record.net,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, true, undefined, false)
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxcpu",
|
||||
accessorFn: (record) => record.maxcpu,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`vCPUs`} Icon={CpuIcon} />,
|
||||
invertSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 tabular-nums">{getValue() as number}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxmem",
|
||||
accessorFn: (record) => record.maxmem,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Max Mem`} Icon={MemoryStickIcon} />,
|
||||
invertSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
// maxmem is stored in bytes; convert to MB for formatBytes
|
||||
const formatted = formatBytes(getValue() as number, false, undefined, false)
|
||||
return <span className="ms-1.5 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorFn: (record) => record.uptime,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Uptime`} Icon={TimerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 w-25 block truncate">{formatUptime(getValue() as number)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({ column, name, Icon }: { column: Column<PveVmRecord>; name: string; Icon: React.ElementType }) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
<ArrowUpDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
368
internal/site/src/components/pve-table/pve-table.tsx
Normal file
368
internal/site/src/components/pve-table/pve-table.tsx
Normal file
@@ -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<PveVmRecord[] | undefined>(undefined)
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-pve-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
function fetchData(systemId?: string) {
|
||||
pb.collection<PveVmRecord>("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<string>()
|
||||
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 (
|
||||
<Card className="p-6 @container w-full">
|
||||
<CardHeader className="p-0 mb-4">
|
||||
<div className="grid md:flex gap-5 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Proxmox VMs</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>CPU is percent of overall host CPU usage.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ps-4 pe-10 w-full"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<AllPveTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const AllPveTable = memo(function AllPveTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
data,
|
||||
}: {
|
||||
table: TableType<PveVmRecord>
|
||||
rows: Row<PveVmRecord>[]
|
||||
colLength: number
|
||||
data: PveVmRecord[] | undefined
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeVm = useRef<PveVmRecord | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const openSheet = (vm: PveVmRecord) => {
|
||||
activeVm.current = vm
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<PveTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <PveTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
{data ? (
|
||||
<Trans>No results.</Trans>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<PveVmSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeVm={activeVm} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function PveVmSheet({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
activeVm,
|
||||
}: {
|
||||
sheetOpen: boolean
|
||||
setSheetOpen: (open: boolean) => void
|
||||
activeVm: RefObject<PveVmRecord | null>
|
||||
}) {
|
||||
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 (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-120 p-2">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{vm.name}</SheetTitle>
|
||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: vm.system })}>
|
||||
{$allSystemsById.get()[vm.system]?.name ?? ""}
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{vm.type}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<Trans>Up {formatUptime(vm.uptime)}</Trans>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{vm.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="px-3 pb-3 -mt-2 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>Details</Trans>
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>CPU Usage</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(vm.cpu, vm.cpu >= 10 ? 1 : 2)}%`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Memory Used</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(memFormatted.value, memFormatted.value >= 10 ? 1 : 2)} ${memFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Network</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(netFormatted.value, netFormatted.value >= 10 ? 1 : 2)} ${netFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>vCPUs</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{vm.maxcpu}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Max Memory</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(maxMemFormatted.value, maxMemFormatted.value >= 10 ? 1 : 2)} ${maxMemFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Uptime</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{formatUptime(vm.uptime)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function PveTableHead({ table }: { table: TableType<PveVmRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const PveTableRow = memo(function PveTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<PveVmRecord>
|
||||
virtualRow: VirtualItem
|
||||
openSheet: (vm: PveVmRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer transition-opacity"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0 ps-4.5"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
|
||||
const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
proxmox: "/proxmox",
|
||||
smart: "/smart",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
|
||||
26
internal/site/src/components/routes/proxmox.tsx
Normal file
26
internal/site/src/components/routes/proxmox.tsx
Normal file
@@ -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(
|
||||
() => (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<PveTable />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
@@ -185,3 +185,12 @@ export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// simple-icons (CC0) https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md
|
||||
export function ProxmoxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" strokeWidth="1.5" fill="none">
|
||||
<path d="M5 1.8c-1.2.6-1.2.7-.1 1.8l7 7.8c.2 0 8-8.6 8.1-8.8l-.5-.5q-.5-.5-1.7-.5c-1.6-.1-2.2.2-4.1 2.4L12 6 10.4 4 8 1.9c-.8-.4-2.4-.5-3.2 0M1.2 4.4q-1.2.5-1.3.8l3 3.5L5.8 12l-3 3.3L0 18.8c.1.5 1.5 1 2.6 1 1.7 0 2-.2 5.6-4.1l3.2-3.7a74 74 0 0 0-7.1-7.5c-.9-.4-2.2-.5-3-.1m18.5 0q-.7.4-4 4L12.6 12l3.3 3.7c3.5 3.9 3.9 4.2 5.6 4.2 1 0 2.4-.6 2.5-1 0-.2-1.3-1.8-2.9-3.6L18 12l3-3.3c1.6-1.8 3-3.3 2.9-3.5 0-.4-1.4-1-2.5-1q-1 0-1.7.3M8 17l-4 4.4.5.6q.6.4 1.7.4c1.6.1 2.2-.2 4.2-2.5l1.6-1.8 1.7 1.8c2 2.3 2.5 2.6 4 2.5q1.3 0 1.8-.4t.5-.6c0-.2-7.9-8.8-8-8.8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <SystemDetail id={page.params.id} />
|
||||
} else if (page.route === "containers") {
|
||||
return <Containers />
|
||||
} else if (page.route === "proxmox") {
|
||||
return <Proxmox />
|
||||
} else if (page.route === "smart") {
|
||||
return <Smart />
|
||||
} else if (page.route === "settings") {
|
||||
|
||||
22
internal/site/src/types.d.ts
vendored
22
internal/site/src/types.d.ts
vendored
@@ -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 {
|
||||
|
||||
133
pve-plan.md
133
pve-plan.md
@@ -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:
|
||||
|
||||
<ContainerChart chartData={pveSyntheticChartData} dataKey="c" chartType={ChartType.CPU} chartConfig={pveChartConfigs.cpu} />
|
||||
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)
|
||||
Reference in New Issue
Block a user