Files
homarr/packages/widget-sdk/examples/beszel.json
T

49 lines
15 KiB
JSON

{
"$schema": "homarr-widget-package-v3",
"manifest": {
"id": "homarr.reference.beszel",
"version": "1.0.0",
"sdkVersion": "1",
"name": "Custom Beszel systems",
"description": "Two attributable Beszel sources, native persisted table layouts, eight history panels and bounded live metrics with partial-failure recovery.",
"entrypoints": {
"tile": "systems.tsx",
"advanced": "systems.tsx",
"server": "server.ts"
},
"handlers": {
"systems": {
"kind": "query",
"permission": "view"
},
"stats": {
"kind": "query",
"permission": "view"
},
"live": {
"kind": "subscription",
"permission": "view",
"timeoutMs": 300000
}
}
},
"files": {
"server.ts": "import type { AttributedSystem, NativeResult, SystemRow, StatsResult, StatsInput, LiveStatsEvent } from \"./beszel-types\";\nimport { callWidgetIntegration, defineWidgetServer, getWidgetServerContext, subscribeWidgetIntegration } from \"@homarr/widget-sdk/server\";\nconst declaredConnections = new Set([\"beszel\", \"secondary\"]);\nconst getConnection = (input: {connection?:string} | undefined) => {\n const connection = input?.connection ?? \"beszel\";\n if (!declaredConnections.has(connection)) throw new Error(\"Unknown Beszel connection\");\n return connection;\n};\nexport default defineWidgetServer({\n systems: async (_input, context) => {\n const {bindingNames} = await getWidgetServerContext(context);\n const names = [...declaredConnections].filter((name) => bindingNames.includes(name));\n if (!names.length) throw new Error(\"Bind a Beszel integration to this widget first\");\n const results = await Promise.allSettled(names.map((connection) => callWidgetIntegration<NativeResult<SystemRow[]>>(context, {connection, capability:\"beszel.systems\"})));\n const data: AttributedSystem[] = [];\n const sources = results.map((result, index) => {\n const connection = names[index]!;\n if (result.status === \"rejected\") return {connection,ok:false,error:result.reason instanceof Error ? result.reason.message : String(result.reason)};\n data.push(...result.value.data.map((system) => ({...system,connection,rowKey:`${connection}:${system.id}`})));\n return {connection,ok:true,timestamp:result.value.timestamp};\n });\n return {data,sources};\n },\n stats: (input: StatsInput, context) => callWidgetIntegration<NativeResult<StatsResult>>(context, {connection:getConnection(input),capability:\"beszel.stats\",input}),\n live: (input: StatsInput, context) => subscribeWidgetIntegration<LiveStatsEvent>(context, {connection:getConnection(input),capability:\"beszel.live\",input}),\n});",
"beszel-types.ts": "// Public result fields used from the Homarr Beszel capability adapter.\nexport interface SystemRow {\n id:string; name:string; status:\"up\"|\"down\"|\"paused\"|\"pending\";\n cpu:number; memory:number; disk:number; gpu:number; netBytes:number; uptime:number;\n hostname:string; cpuModel:string; cores:number; osName:string; agentVersion:string;\n}\nexport interface AttributedSystem extends SystemRow {connection:string;rowKey:string}\nexport interface NativeResult<T> {data:T;timestamp?:string}\nexport interface SystemStats {cpu:number;mu:number;mb:number;du:number;dio?:[number,number];dr?:number;dw?:number;b?:[number,number];ns?:number;nr?:number}\nexport interface ContainerStats {n:string;c:number;m:number;b?:[number,number];ns?:number;nr?:number}\nexport interface StatsRecord<T> {id:string;created:string;stats:T}\nexport type SystemRecord = StatsRecord<SystemStats>;\nexport type ContainerRecord = StatsRecord<ContainerStats[]>;\nexport interface StatsResult {systemStats:SystemRecord[];containerStats:ContainerRecord[]}\nexport type LiveStatsEvent = {type:\"system_stats\";record:SystemRecord}|{type:\"container_stats\";record:ContainerRecord};\nexport interface StatsInput {connection?:string;systemId:string;timePeriod?:string;includeDocker?:boolean}\n\nexport interface SystemsResult {data:AttributedSystem[];sources:{connection:string;ok:boolean;error?:string;timestamp?:string}[]}\n",
"systems.tsx": "import type { AttributedSystem, SystemStats, ContainerStats, LiveStatsEvent, SystemsResult, NativeResult, StatsResult } from \"./beszel-types\";\nimport type { DataTableColumn, DataTableSortStatus } from \"@homarr/widget-sdk/ui\";\ntype Layout = {columnOrder:string;columnWidths:string};\ntype Selection = {connection:string;id:string;name:string};\ntype ChartData = Array<{time:string} & Record<string,string|number>>;\nimport { useCallback, useMemo } from \"react\";\nimport { Alert, Badge, Button, Group, Loader, ScrollArea, SegmentedControl, SimpleGrid, Stack, Text, TextInput } from \"@mantine/core\";\nimport { LineChart } from \"@mantine/charts\";\nimport { useWidgetHost, useWidgetQuery, useWidgetServices, useWidgetState, useWidgetSubscription, useLocalWidgetState } from \"@homarr/widget-sdk\";\nimport { HomarrDataTable, usePersistedTableLayout } from \"@homarr/widget-sdk/ui\";\n\nconst columnAccessors = [\"name\", \"connection\", \"hostname\", \"cpu\", \"memory\", \"disk\", \"gpu\", \"netBytes\", \"uptime\", \"cpuModel\", \"cores\", \"osName\", \"agentVersion\"];\nconst colors = [\"teal.6\", \"blue.6\", \"grape.6\", \"orange.6\", \"pink.6\", \"cyan.6\"];\nconst mib = 1024 ** 2;\nconst gib = 1024 ** 3;\nconst rate = (bytes: number | undefined, legacy: number | undefined) => bytes ?? (legacy ?? 0) * mib;\nconst order = <T extends {created:string}>(records: readonly T[]) => [...records].sort((left, right) => Date.parse(left.created) - Date.parse(right.created));\n\nfunction Chart({ title, data, names, unit, locale }: {title:string;data:ChartData;names:string[];unit:string;locale:string}) {\n const formatter = (value:number) => {\n if (unit === \"%\") return new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value) + \"%\";\n return new Intl.NumberFormat(locale, { notation: \"compact\", maximumFractionDigits: 1 }).format(value) + \" \" + unit;\n };\n return <Stack gap={4} style={{ minWidth: 0 }}>\n <Text size=\"sm\" fw={600}>{title}</Text>\n {data.length === 0 ? <Text c=\"dimmed\" size=\"xs\">Waiting for samples</Text> :\n <LineChart h={180} data={data} dataKey=\"time\" series={names.map((name, index) => ({ name, color: colors[index % colors.length] }))} curveType=\"linear\" valueFormatter={formatter} withDots={false} />}\n </Stack>;\n}\n\nexport default function Systems() {\n const host = useWidgetHost();\n const services = useWidgetServices();\n const systems = useWidgetQuery<SystemsResult>(\"systems\", {}, { refetchInterval: 10000 });\n const [selected, setSelected] = useWidgetState<Selection | null>(\"system\", null);\n const [period, setPeriod] = useWidgetState(\"period\", \"1h\");\n const [search, setSearch] = useWidgetState(\"search\", \"\");\n const [sort, setSort] = useWidgetState<DataTableSortStatus<AttributedSystem>>(\"sort\", { columnAccessor: \"name\", direction: \"asc\" });\n const advanced = host.displayMode === \"advanced\";\n const live = useWidgetSubscription<LiveStatsEvent>(\"live\", { connection: selected?.connection ?? \"beszel\", systemId: selected?.id ?? \"\" }, { enabled: advanced && selected !== null && period === \"live\", maximumEvents: 120 });\n const stats = useWidgetQuery<NativeResult<StatsResult>>(\"stats\", { connection: selected?.connection ?? \"beszel\", systemId: selected?.id ?? \"\", timePeriod: period === \"live\" ? \"1h\" : period, includeDocker: true }, { enabled: advanced && selected !== null && period !== \"live\", refetchInterval: 5000 });\n const records = systems.data?.data ?? [];\n const visible = records.filter((system) => `${system.name} ${system.connection} ${system.hostname}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).toSorted((a, b) => {\n const direction = sort.direction === \"asc\" ? 1 : -1;\n const left = a[sort.columnAccessor as keyof AttributedSystem];\n const right = b[sort.columnAccessor as keyof AttributedSystem];\n if (typeof left === \"number\" && typeof right === \"number\") return (left - right) * direction;\n return String(left ?? \"\").localeCompare(String(right ?? \"\")) * direction;\n });\n const inspect = useCallback((system: AttributedSystem) => {\n setSelected({connection: system.connection, id: system.id, name: system.name});\n if (!advanced) services.openAdvanced();\n }, [advanced, services, setSelected]);\n const [layout, setLayout] = useLocalWidgetState({\n key: host.isPreview ? undefined : `custom-widget:beszel-layout:${host.itemId}:${host.userId ?? \"guest\"}`,\n version: 1, defaultValue: {columnOrder: \"\", columnWidths: \"\"},\n validate: (value): value is Layout => Boolean(value && typeof value === \"object\" && \"columnOrder\" in value && \"columnWidths\" in value && typeof value.columnOrder === \"string\" && typeof value.columnWidths === \"string\"),\n });\n const persistLayout = useCallback((patch: Partial<Layout>) => setLayout((current) => ({...current, ...patch})), [setLayout]);\n const columns = useMemo<DataTableColumn<AttributedSystem>[]>(() => [\n {accessor:\"name\", title:\"System\", width:180, sortable:true, render: (system: AttributedSystem) => <Group gap={4} wrap=\"nowrap\"><Badge size=\"xs\" color={system.status === \"up\" ? \"teal\" : \"gray\"}>{system.status}</Badge><Button variant=\"subtle\" size=\"compact-xs\" onClick={(event) => {event.stopPropagation(); inspect(system);}}>{system.name}</Button></Group>},\n {accessor:\"connection\", title:\"Source\", width:100, sortable:true, hidden:!advanced},\n {accessor:\"hostname\", title:\"Hostname\", width:140, sortable:true, hidden:!advanced},\n ...([\"cpu\",\"memory\",\"disk\",\"gpu\"] as const).map((key) => ({accessor:key, title:key.toUpperCase(), width:100, sortable:true, hidden:!advanced && (key === \"gpu\" || (key === \"disk\" && host.visibleWidth < 420)), render:(system: AttributedSystem) => <Text size=\"xs\">{system[key].toFixed(1)}%</Text>})),\n {accessor:\"netBytes\", title:\"Network\", width:110, sortable:true, hidden:!advanced, render:(system: AttributedSystem) => <Text size=\"xs\">{(system.netBytes / mib).toFixed(2)} MiB/s</Text>},\n {accessor:\"uptime\", title:\"Uptime\", width:100, sortable:true, hidden:!advanced, render:(system: AttributedSystem) => <Text size=\"xs\">{Math.floor(system.uptime / 3600)} h</Text>},\n {accessor:\"cpuModel\", title:\"CPU model\", width:180, hidden:!advanced},\n {accessor:\"cores\", title:\"Cores\", width:70, sortable:true, hidden:!advanced},\n {accessor:\"osName\", title:\"Operating system\", width:140, sortable:true, hidden:!advanced},\n {accessor:\"agentVersion\", title:\"Agent\", width:90, hidden:!advanced},\n ], [advanced, host.visibleWidth, inspect]);\n const {effectiveColumns, storeKey} = usePersistedTableLayout({\n columns, columnAccessors, columnOrder:layout.columnOrder, columnWidths:layout.columnWidths,\n itemId:host.itemId ?? host.previewId, storeKeyPrefix:`custom-beszel-${host.userId ?? \"guest\"}`, onLayoutChange:persistLayout,\n });\n const panels = useMemo(() => {\n const systemRecords = order(period === \"live\" ? live.events.filter((event) => event.type === \"system_stats\").map((event) => event.record) : stats.data?.data?.systemStats ?? []);\n const containerRecords = order(period === \"live\" ? live.events.filter((event) => event.type === \"container_stats\").map((event) => event.record) : stats.data?.data?.containerStats ?? []);\n const formatTime = (created:string) => new Date(created).toLocaleTimeString(host.locale, { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const mapped = (fn:(stats:SystemStats)=>Record<string,number>) => systemRecords.map((record) => ({ time: formatTime(record.created), ...fn(record.stats) }));\n const containerNames = [...new Set(containerRecords.flatMap((record) => record.stats.map((container) => container.n)))];\n const containers = (fn:(stats:ContainerStats)=>number) => containerRecords.map((record) => ({ time: formatTime(record.created), ...Object.fromEntries(record.stats.map((container) => [container.n, fn(container)])) }));\n return [\n { title: \"CPU\", data: mapped((s) => ({ CPU: s.cpu })), names: [\"CPU\"], unit: \"%\" },\n { title: \"Memory\", data: mapped((s) => ({ Used: s.mu * gib, Cache: s.mb * gib })), names: [\"Used\", \"Cache\"], unit: \"B\" },\n { title: \"Disk\", data: mapped((s) => ({ Used: s.du * gib })), names: [\"Used\"], unit: \"B\" },\n { title: \"Disk I/O\", data: mapped((s) => ({ Read: rate(s.dio?.[0], s.dr), Write: rate(s.dio?.[1], s.dw) })), names: [\"Read\", \"Write\"], unit: \"B/s\" },\n { title: \"Network\", data: mapped((s) => ({ Sent: rate(s.b?.[0], s.ns), Received: rate(s.b?.[1], s.nr) })), names: [\"Sent\", \"Received\"], unit: \"B/s\" },\n { title: \"Container CPU\", data: containers((c) => c.c), names: containerNames, unit: \"%\" },\n { title: \"Container memory\", data: containers((c) => c.m * mib), names: containerNames, unit: \"B\" },\n { title: \"Container network\", data: containers((c) => rate(c.b?.[0], c.ns) + rate(c.b?.[1], c.nr)), names: containerNames, unit: \"B/s\" },\n ];\n }, [host.locale, live.events, period, stats.data]);\n return <ScrollArea h=\"100%\"><Stack gap=\"sm\" p=\"sm\">\n <Group justify=\"space-between\"><Text fw={600}>Beszel systems</Text>{systems.isFetching && <Loader size=\"xs\" />}</Group>\n {systems.error && <Alert color=\"red\">{systems.error.message}<Button variant=\"subtle\" onClick={() => systems.refetch()}>Retry</Button></Alert>}\n {advanced && <TextInput aria-label=\"Filter systems\" placeholder=\"Filter systems and sources\" value={search} onChange={(event) => setSearch(event.currentTarget.value)} />}\n {(systems.data?.sources ?? []).filter((source) => !source.ok).map((source) => <Alert key={source.connection} color=\"yellow\" title={source.connection}>{source.error}</Alert>)}\n <HomarrDataTable isEditMode={host.isEditMode} cellPadding=\"4px 8px\" records={visible} columns={effectiveColumns}\n height={advanced ? 320 : Math.max(100, host.height - 64)} idAccessor=\"rowKey\" storeColumnsKey={storeKey}\n sortStatus={sort} onSortStatusChange={setSort} noRecordsText=\"No systems match this view.\"\n onRowClick={({record}) => inspect(record)} />\n {!systems.isPending && visible.length === 0 && <Text size=\"sm\" c=\"dimmed\">No systems match this view.</Text>}\n {!selected && advanced && <Text size=\"sm\" c=\"dimmed\">Select a system to inspect its history.</Text>}\n {selected && advanced && <Stack gap=\"sm\">\n <Text fw={600}>{selected.name} \u00b7 {selected.connection}</Text>\n <SegmentedControl value={period} onChange={setPeriod} data={[\"live\", \"1h\", \"12h\", \"24h\", \"1w\", \"30d\"]} size=\"xs\" />\n {live.error && period === \"live\" && <Alert color=\"yellow\">{live.error.message}<Group><Button size=\"compact-xs\" onClick={live.reconnect}>Reconnect</Button><Button size=\"compact-xs\" onClick={() => setPeriod(\"1h\")}>Use history</Button></Group></Alert>}\n {stats.error && period !== \"live\" && <Alert color=\"red\">{stats.error.message}</Alert>}\n <SimpleGrid cols={host.visibleWidth > 700 ? 2 : 1}>{panels.map((panel) => <Chart key={panel.title} {...panel} locale={host.locale} />)}</SimpleGrid>\n </Stack>}\n </Stack></ScrollArea>;\n}\n"
},
"dependencies": {},
"connections": {
"beszel": {
"label": "Beszel integration",
"kind": "integration"
},
"secondary": {
"kind": "integration",
"label": "Secondary Beszel integration",
"optional": true
}
},
"options": {}
}