mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 22:16:18 +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 {
|
||||
|
||||
Reference in New Issue
Block a user