From 9c458885f11b6998de628c87d65c48cb7f1a2fea Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 1 Sep 2025 17:29:33 -0400 Subject: [PATCH] refactor (hub): add `systemsManager` module - Removed the `updateSystemList` function and replaced it with a more efficient system management approach using `systemsManager`. - Updated the `App` component to initialize and subscribe to system updates through the new `systemsManager`. - Refactored the `SystemsTable` and `SystemDetail` components to utilize the new state management for systems, improving performance and maintainability. - Enhanced the `ActiveAlerts` component to fetch system names directly from the new state structure. --- beszel/site/src/components/routes/home.tsx | 26 +-- beszel/site/src/components/routes/system.tsx | 39 +---- .../systems-table/systems-table.tsx | 49 ++++-- beszel/site/src/lib/api.ts | 78 +-------- beszel/site/src/lib/stores.ts | 16 +- beszel/site/src/lib/systemsManager.ts | 161 ++++++++++++++++++ beszel/site/src/lib/utils.ts | 19 +-- beszel/site/src/main.tsx | 43 ++--- 8 files changed, 245 insertions(+), 186 deletions(-) create mode 100644 beszel/site/src/lib/systemsManager.ts diff --git a/beszel/site/src/components/routes/home.tsx b/beszel/site/src/components/routes/home.tsx index 7a540b2..09312e8 100644 --- a/beszel/site/src/components/routes/home.tsx +++ b/beszel/site/src/components/routes/home.tsx @@ -1,12 +1,10 @@ import { Suspense, memo, useEffect, useMemo } from "react" import { Card, CardContent, CardHeader, CardTitle } from "../ui/card" -import { $alerts, $systems } from "@/lib/stores" +import { $alerts, $allSystemsById } from "@/lib/stores" import { useStore } from "@nanostores/react" import { GithubIcon } from "lucide-react" import { Separator } from "../ui/separator" -import { getSystemNameFromId } from "@/lib/utils" -import { pb, updateRecordList, updateSystemList } from "@/lib/api" -import { AlertRecord, SystemRecord } from "@/types" +import { AlertRecord } from "@/types" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { $router, Link } from "../router" import { Plural, Trans, useLingui } from "@lingui/react/macro" @@ -14,8 +12,6 @@ import { getPagePath } from "@nanostores/router" import { alertInfo } from "@/lib/alerts" import SystemsTable from "@/components/systems-table/systems-table" -// const SystemsTable = lazy(() => import("../systems-table/systems-table")) - export default memo(function () { const { t } = useLingui() @@ -23,19 +19,6 @@ export default memo(function () { document.title = t`Dashboard` + " / Beszel" }, [t]) - useEffect(() => { - // make sure we have the latest list of systems - updateSystemList() - - // subscribe to real time updates for systems / alerts - pb.collection("systems").subscribe("*", (e) => { - updateRecordList(e, $systems) - }) - return () => { - pb.collection("systems").unsubscribe("*") - } - }, []) - return useMemo( () => ( <> @@ -69,6 +52,7 @@ export default memo(function () { const ActiveAlerts = () => { const alerts = useStore($alerts) + const systems = useStore($allSystemsById) const { activeAlerts, alertsKey } = useMemo(() => { const activeAlerts: AlertRecord[] = [] @@ -112,7 +96,7 @@ const ActiveAlerts = () => { > - {getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")} + {systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")} {alert.name === "Status" ? ( @@ -125,7 +109,7 @@ const ActiveAlerts = () => { )} diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index b3e34d5..6d632fe 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -8,6 +8,7 @@ import { $direction, $maxValues, $temperatureFilter, + $allSystemsByName, } from "@/lib/stores" import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums" @@ -49,6 +50,7 @@ import SwapChart from "@/components/charts/swap-chart" import TemperatureChart from "@/components/charts/temperature-chart" import GpuPowerChart from "@/components/charts/gpu-power-chart" import LoadAverageChart from "@/components/charts/load-average-chart" +import { subscribeKeys } from "nanostores" const cache = new Map() @@ -117,7 +119,7 @@ function dockerOrPodman(str: string, system: SystemRecord) { return str } -export default function SystemDetail({ name }: { name: string }) { +export default memo(function SystemDetail({ name }: { name: string }) { const direction = useStore($direction) const { t } = useLingui() const systems = useStore($systems) @@ -149,36 +151,13 @@ export default function SystemDetail({ name }: { name: string }) { } }, [name]) - // function resetCharts() { - // setSystemStats([]) - // setContainerData([]) - // } - - // useEffect(resetCharts, [chartTime]) - - // find matching system + // find matching system and update when it changes useEffect(() => { - if (system.id && system.name === name) { - return - } - const matchingSystem = systems.find((s) => s.name === name) as SystemRecord - if (matchingSystem) { - setSystem(matchingSystem) - } - }, [name, system, systems]) - - // update system when new data is available - useEffect(() => { - if (!system.id) { - return - } - pb.collection("systems").subscribe(system.id, (e) => { - setSystem(e.record) + return subscribeKeys($allSystemsByName, [name], (newSystems) => { + const sys = newSystems[name] + sys?.id && setSystem(sys) }) - return () => { - pb.collection("systems").unsubscribe(system.id) - } - }, [system.id]) + }, [name]) const chartData: ChartData = useMemo(() => { const lastCreated = Math.max( @@ -835,7 +814,7 @@ export default function SystemDetail({ name }: { name: string }) { {bottomSpacing > 0 && } ) -} +}) function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) { const containerFilter = useStore(store) diff --git a/beszel/site/src/components/systems-table/systems-table.tsx b/beszel/site/src/components/systems-table/systems-table.tsx index 060734f..5b39262 100644 --- a/beszel/site/src/components/systems-table/systems-table.tsx +++ b/beszel/site/src/components/systems-table/systems-table.tsx @@ -36,9 +36,9 @@ import { FilterIcon, } from "lucide-react" import { memo, useEffect, useMemo, useRef, useState } from "react" -import { $systems } from "@/lib/stores" +import { $pausedSystems, $downSystems, $upSystems, $systems } from "@/lib/stores" import { useStore } from "@nanostores/react" -import { cn, runOnce, useLocalStorage } from "@/lib/utils" +import { cn, runOnce, useBrowserStorage } from "@/lib/utils" import { $router, Link } from "../router" import { useLingui, Trans } from "@lingui/react/macro" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" @@ -50,12 +50,15 @@ import { SystemStatus } from "@/lib/enums" import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual" type ViewMode = "table" | "grid" -type StatusFilter = "all" | "up" | "down" | "paused" +type StatusFilter = "all" | SystemRecord["status"] const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx")) export default function SystemsTable() { const data = useStore($systems) + const downSystems = $downSystems.get() + const upSystems = $upSystems.get() + const pausedSystems = $pausedSystems.get() const { i18n, t } = useLingui() const [filter, setFilter] = useState() const [statusFilter, setStatusFilter] = useState("all") @@ -74,7 +77,13 @@ export default function SystemsTable() { if (statusFilter === "all") { return data } - return data.filter((system) => system.status === statusFilter) + if (statusFilter === SystemStatus.Up) { + return Object.values(upSystems) ?? [] + } + if (statusFilter === SystemStatus.Down) { + return Object.values(downSystems) ?? [] + } + return Object.values(pausedSystems) ?? [] }, [data, statusFilter]) const [viewMode, setViewMode] = useBrowserStorage( @@ -106,7 +115,6 @@ export default function SystemsTable() { columnVisibility, }, defaultColumn: { - // sortDescFirst: true, invertSorting: true, sortUndefined: "last", minSize: 0, @@ -118,18 +126,22 @@ export default function SystemsTable() { const rows = table.getRowModel().rows const columns = table.getAllColumns() const visibleColumns = table.getVisibleLeafColumns() + + const [upSystemsLength, downSystemsLength, pausedSystemsLength] = useMemo(() => { + return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length] + }, [upSystems, downSystems, pausedSystems]) + // TODO: hiding temp then gpu messes up table headers const CardHead = useMemo(() => { return ( - +
- + All Systems - + - Click on a system to view information - {runningRecords} / {totalRecords} -

Online

+ Click on a system to view more information.
@@ -181,13 +193,13 @@ export default function SystemsTable() { All Systems e.preventDefault()}> - Up + Up ({upSystemsLength}) e.preventDefault()}> - Down + Down ({downSystemsLength}) e.preventDefault()}> - Paused + Paused ({pausedSystemsLength})
@@ -258,7 +270,16 @@ export default function SystemsTable() {
) - }, [visibleColumns.length, sorting, viewMode, locale, statusFilter, runningRecords, totalRecords]) + }, [ + visibleColumns.length, + sorting, + viewMode, + locale, + statusFilter, + upSystemsLength, + downSystemsLength, + pausedSystemsLength, + ]) return ( diff --git a/beszel/site/src/lib/api.ts b/beszel/site/src/lib/api.ts index 1cd6391..a81e923 100644 --- a/beszel/site/src/lib/api.ts +++ b/beszel/site/src/lib/api.ts @@ -1,10 +1,8 @@ -import { ChartTimes, SystemRecord, UserSettings } from "@/types" -import { $alerts, $longestSystemNameLen, $systems, $userSettings } from "./stores" +import { ChartTimes, UserSettings } from "@/types" +import { $alerts, $allSystemsByName, $userSettings } from "./stores" import { toast } from "@/components/ui/use-toast" import { t } from "@lingui/core/macro" import { chartTimeData } from "./utils" -import { WritableAtom } from "nanostores" -import { RecordModel, RecordSubscription } from "pocketbase" import PocketBase from "pocketbase" import { basePath } from "@/components/router" @@ -14,7 +12,7 @@ export const pb = new PocketBase(basePath) export const isAdmin = () => pb.authStore.record?.role === "admin" export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly" -const verifyAuth = () => { +export const verifyAuth = () => { pb.collection("users") .authRefresh() .catch(() => { @@ -29,7 +27,7 @@ const verifyAuth = () => { /** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */ export async function logOut() { - $systems.set([]) + $allSystemsByName.set({}) $alerts.set({}) $userSettings.set({} as UserSettings) sessionStorage.setItem("lo", "t") // prevent auto login on logout @@ -54,74 +52,6 @@ export async function updateUserSettings() { console.error("create settings", e) } } -/** Update systems / alerts list when records change */ -export function updateRecordList(e: RecordSubscription, $store: WritableAtom) { - const curRecords = $store.get() - const newRecords = [] - if (e.action === "delete") { - for (const server of curRecords) { - if (server.id !== e.record.id) { - newRecords.push(server) - } - } - } else { - let found = 0 - for (const server of curRecords) { - if (server.id === e.record.id) { - found = newRecords.push(e.record) - } else { - newRecords.push(server) - } - } - if (!found) { - newRecords.push(e.record) - } - } - $store.set(newRecords) -} -/** Fetches updated system list from database */ -export const updateSystemList = (() => { - let isFetchingSystems = false - return async () => { - if (isFetchingSystems) { - return - } - isFetchingSystems = true - try { - let records = await pb - .collection("systems") - .getFullList({ sort: "+name", fields: "id,name,host,port,info,status" }) - - if (records.length) { - // records = [ - // ...records, - // ...records, - // ...records, - // ...records, - // ...records, - // ...records, - // ...records, - // ...records, - // ...records, - // ] - // we need to loop once to get the longest name - let longestName = $longestSystemNameLen.get() - for (const { name } of records) { - const nameLen = Math.min(20, name.length) - if (nameLen > longestName) { - $longestSystemNameLen.set(nameLen) - longestName = nameLen - } - } - $systems.set(records) - } else { - verifyAuth() - } - } finally { - isFetchingSystems = false - } - } -})() export function getPbTimestamp(timeString: ChartTimes, d?: Date) { d ||= chartTimeData[timeString].getOffset(new Date()) diff --git a/beszel/site/src/lib/stores.ts b/beszel/site/src/lib/stores.ts index f76b6db..985fbff 100644 --- a/beszel/site/src/lib/stores.ts +++ b/beszel/site/src/lib/stores.ts @@ -1,4 +1,4 @@ -import { atom, map } from "nanostores" +import { atom, computed, map, ReadableAtom } from "nanostores" import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types" import { Unit } from "./enums" import { pb } from "./api" @@ -6,8 +6,18 @@ import { pb } from "./api" /** Store if user is authenticated */ export const $authenticated = atom(pb.authStore.isValid) -/** List of system records */ -export const $systems = atom([]) +/** Map of system records by name */ +export const $allSystemsByName = map>({}) +/** Map of system records by id */ +export const $allSystemsById = map>({}) +/** Map of up systems by id */ +export const $upSystems = map>({}) +/** Map of down systems by id */ +export const $downSystems = map>({}) +/** Map of paused systems by id */ +export const $pausedSystems = map>({}) +/** List of all system records */ +export const $systems: ReadableAtom = computed($allSystemsByName, Object.values) /** Map of alert records by system id and alert name */ export const $alerts = map({}) diff --git a/beszel/site/src/lib/systemsManager.ts b/beszel/site/src/lib/systemsManager.ts new file mode 100644 index 0000000..c539200 --- /dev/null +++ b/beszel/site/src/lib/systemsManager.ts @@ -0,0 +1,161 @@ +import { SystemRecord } from "@/types" +import { PreinitializedMapStore } from "nanostores" +import { pb, verifyAuth } from "@/lib/api" +import { + $allSystemsByName, + $upSystems, + $downSystems, + $pausedSystems, + $allSystemsById, + $longestSystemNameLen, +} from "@/lib/stores" +import { updateFavicon, FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED } from "@/lib/utils" +import { SystemStatus } from "./enums" + +const COLLECTION = pb.collection("systems") +const FIELDS_DEFAULT = "id,name,host,port,info,status" + +/** Maximum system name length for display purposes */ +const MAX_SYSTEM_NAME_LENGTH = 20 + +let initialized = false +let unsub: (() => void) | undefined | void + +/** Initialize the systems manager and set up listeners */ +export function init() { + if (initialized) { + return + } + initialized = true + + // sync system stores on change + $allSystemsByName.listen((newSystems, oldSystems, changedKey) => { + const oldSystem = oldSystems[changedKey] + const newSystem = newSystems[changedKey] + + // if system is undefined (deleted), remove it from the stores + if (oldSystem && !newSystem?.id) { + removeFromStore(oldSystem, $upSystems) + removeFromStore(oldSystem, $downSystems) + removeFromStore(oldSystem, $pausedSystems) + removeFromStore(oldSystem, $allSystemsById) + } + + if (!newSystem) { + onSystemsChanged(newSystems, undefined) + return + } + + const newStatus = newSystem.status + if (newStatus === SystemStatus.Up) { + $upSystems.setKey(newSystem.id, newSystem) + removeFromStore(newSystem, $downSystems) + removeFromStore(newSystem, $pausedSystems) + } else if (newStatus === SystemStatus.Down) { + $downSystems.setKey(newSystem.id, newSystem) + removeFromStore(newSystem, $upSystems) + removeFromStore(newSystem, $pausedSystems) + } else if (newStatus === SystemStatus.Paused) { + $pausedSystems.setKey(newSystem.id, newSystem) + removeFromStore(newSystem, $upSystems) + removeFromStore(newSystem, $downSystems) + } + + // run things that need to be done when systems change + onSystemsChanged(newSystems, newSystem) + }) +} + +/** Update the longest system name length and favicon based on system status */ +function onSystemsChanged(_: Record, changedSystem: SystemRecord | undefined) { + const upSystemsStore = $upSystems.get() + const downSystemsStore = $downSystems.get() + const upSystems = Object.values(upSystemsStore) + const downSystems = Object.values(downSystemsStore) + + // Update longest system name length + const longestName = $longestSystemNameLen.get() + const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, changedSystem?.name.length || 0) + if (nameLen > longestName) { + $longestSystemNameLen.set(nameLen) + } + + // Update favicon based on system status + if (downSystems.length > 0) { + updateFavicon(FAVICON_RED) + } else if (upSystems.length > 0) { + updateFavicon(FAVICON_GREEN) + } else { + updateFavicon(FAVICON_DEFAULT) + } +} + +/** Fetch systems from collection */ +async function fetchSystems(): Promise { + try { + return await COLLECTION.getFullList({ sort: "+name", fields: FIELDS_DEFAULT }) + } catch (error) { + console.error("Failed to fetch systems:", error) + return [] + } +} + +// Store management functions +/** Add system to both name and ID stores */ +export function add(system: SystemRecord) { + $allSystemsByName.setKey(system.name, system) + $allSystemsById.setKey(system.id, system) +} + +/** Remove system from stores */ +export function remove(system: SystemRecord) { + removeFromStore(system, $allSystemsByName) + removeFromStore(system, $allSystemsById) + removeFromStore(system, $upSystems) + removeFromStore(system, $downSystems) + removeFromStore(system, $pausedSystems) +} + +/** Remove system from specific store */ +function removeFromStore(system: SystemRecord, store: PreinitializedMapStore>) { + const key = store === $allSystemsByName ? system.name : system.id + store.setKey(key, undefined as any) +} + +/** Action functions for subscription */ +const actionFns: Record void> = { + create: add, + update: add, + delete: remove, +} + +/** Subscribe to real-time system updates from the collection */ +export async function subscribe() { + try { + unsub = await COLLECTION.subscribe("*", ({ action, record }) => actionFns[action]?.(record), { + fields: FIELDS_DEFAULT, + }) + } catch (error) { + console.error("Failed to subscribe to systems collection:", error) + } +} + +/** Refresh all systems with latest data from the hub */ +export async function refresh() { + try { + const records = await fetchSystems() + if (!records.length) { + // No systems found, verify authentication + verifyAuth() + return + } + for (const record of records) { + add(record) + } + } catch (error) { + console.error("Failed to refresh systems:", error) + } +} + +/** Unsubscribe from real-time system updates */ +export const unsubscribe = () => (unsub = unsub?.()) diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts index 2ed94ab..5dbf12f 100644 --- a/beszel/site/src/lib/utils.ts +++ b/beszel/site/src/lib/utils.ts @@ -2,13 +2,17 @@ import { t } from "@lingui/core/macro" import { toast } from "@/components/ui/use-toast" import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" -import { $copyContent, $systems, $userSettings } from "./stores" +import { $copyContent, $userSettings } from "./stores" import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types" import { timeDay, timeHour } from "d3-time" import { useEffect, useState } from "react" import { MeterState, Unit } from "./enums" import { prependBasePath } from "@/components/router" +export const FAVICON_DEFAULT = "favicon.svg" +export const FAVICON_GREEN = "favicon-green.svg" +export const FAVICON_RED = "favicon-red.svg" + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } @@ -344,19 +348,6 @@ export function debounce any>(func: T, wait: numbe } } -/* returns the name of a system from its id */ -export const getSystemNameFromId = (() => { - const cache = new Map() - return (systemId: string): string => { - if (cache.has(systemId)) { - return cache.get(systemId)! - } - const sysName = $systems.get().find((s) => s.id === systemId)?.name ?? "" - cache.set(systemId, sysName) - return sysName - } -})() - /** Run a function only once */ export function runOnce any>(fn: T): (...args: Parameters) => ReturnType { let done = false diff --git a/beszel/site/src/main.tsx b/beszel/site/src/main.tsx index eaf0ea7..d241d47 100644 --- a/beszel/site/src/main.tsx +++ b/beszel/site/src/main.tsx @@ -4,17 +4,16 @@ import { Suspense, lazy, memo, useEffect } from "react" import ReactDOM from "react-dom/client" import { ThemeProvider } from "./components/theme-provider.tsx" import { DirectionProvider } from "@radix-ui/react-direction" -import { $authenticated, $systems, $publicKey, $copyContent, $direction } from "./lib/stores.ts" -import { pb, updateSystemList, updateUserSettings } from "./lib/api.ts" +import { $authenticated, $publicKey, $copyContent, $direction } from "./lib/stores.ts" +import { pb, updateUserSettings } from "./lib/api.ts" +import * as systemsManager from "./lib/systemsManager.ts" import { useStore } from "@nanostores/react" import { Toaster } from "./components/ui/toaster.tsx" import { $router } from "./components/router.tsx" -import { updateFavicon } from "@/lib/utils" import Navbar from "./components/navbar.tsx" import { I18nProvider } from "@lingui/react" import { i18n } from "@lingui/core" import { getLocale, dynamicActivate } from "./lib/i18n" -import { SystemStatus } from "./lib/enums" import { alertManager } from "./lib/alerts" import Settings from "./components/routes/settings/layout.tsx" @@ -25,8 +24,6 @@ const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard. const App = memo(() => { const page = useStore($router) - const authenticated = useStore($authenticated) - const systems = useStore($systems) useEffect(() => { // change auth store on auth change @@ -37,40 +34,26 @@ const App = memo(() => { pb.send("/api/beszel/getkey", {}).then((data) => { $publicKey.set(data.key) }) - // get servers / alerts / settings + // get user settings updateUserSettings() // need to get system list before alerts - updateSystemList() - // get alerts + systemsManager.init() + systemsManager + // get current systems list + .refresh() + // subscribe to new system updates + .then(systemsManager.subscribe) + // get current alerts .then(alertManager.refresh) // subscribe to new alert updates .then(alertManager.subscribe) - return () => { - updateFavicon("favicon.svg") + // updateFavicon("favicon.svg") alertManager.unsubscribe() + systemsManager.unsubscribe() } }, []) - // update favicon - useEffect(() => { - if (!systems.length || !authenticated) { - updateFavicon("favicon.svg") - } else { - let up = false - for (const system of systems) { - if (system.status === SystemStatus.Down) { - updateFavicon("favicon-red.svg") - return - } - if (system.status === SystemStatus.Up) { - up = true - } - } - updateFavicon(up ? "favicon-green.svg" : "favicon.svg") - } - }, [systems]) - if (!page) { return

404

} else if (page.route === "home") {