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.
This commit is contained in:
henrygd
2025-09-01 17:29:33 -04:00
parent d2aed0dc72
commit 9c458885f1
8 changed files with 245 additions and 186 deletions

View File

@@ -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<SystemRecord>("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 = () => {
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
@@ -125,7 +109,7 @@ const ActiveAlerts = () => {
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
href={getPagePath($router, "system", { name: systems[alert.system]?.name })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>

View File

@@ -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<string, any>()
@@ -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<SystemRecord>("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 && <span className="block" style={{ height: bottomSpacing }} />}
</>
)
}
})
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const containerFilter = useStore(store)

View File

@@ -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<string>()
const [statusFilter, setStatusFilter] = useState<StatusFilter>("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<ViewMode>(
@@ -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 (
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2.5">
<CardTitle className="mb-2">
<Trans>All Systems</Trans>
</CardTitle>
</CardTitle>
<CardDescription className="flex">
<Trans>Click on a system to view information - {runningRecords} / {totalRecords}</Trans>
<p className={"ml-2 text-" + (runningRecords === totalRecords ? "emerald" : "red") + "-600"}>Online</p>
<Trans>Click on a system to view more information.</Trans>
</CardDescription>
</div>
@@ -181,13 +193,13 @@ export default function SystemsTable() {
<Trans>All Systems</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
<Trans>Up</Trans>
<Trans>Up ({upSystemsLength})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
<Trans>Down</Trans>
<Trans>Down ({downSystemsLength})</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
<Trans>Paused</Trans>
<Trans>Paused ({pausedSystemsLength})</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</div>
@@ -258,7 +270,16 @@ export default function SystemsTable() {
</div>
</CardHeader>
)
}, [visibleColumns.length, sorting, viewMode, locale, statusFilter, runningRecords, totalRecords])
}, [
visibleColumns.length,
sorting,
viewMode,
locale,
statusFilter,
upSystemsLength,
downSystemsLength,
pausedSystemsLength,
])
return (
<Card>

View File

@@ -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<T extends RecordModel>(e: RecordSubscription<T>, $store: WritableAtom<T[]>) {
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<SystemRecord>("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())

View File

@@ -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<SystemRecord[]>([])
/** Map of system records by name */
export const $allSystemsByName = map<Record<string, SystemRecord>>({})
/** Map of system records by id */
export const $allSystemsById = map<Record<string, SystemRecord>>({})
/** Map of up systems by id */
export const $upSystems = map<Record<string, SystemRecord>>({})
/** Map of down systems by id */
export const $downSystems = map<Record<string, SystemRecord>>({})
/** Map of paused systems by id */
export const $pausedSystems = map<Record<string, SystemRecord>>({})
/** List of all system records */
export const $systems: ReadableAtom<SystemRecord[]> = computed($allSystemsByName, Object.values)
/** Map of alert records by system id and alert name */
export const $alerts = map<AlertMap>({})

View File

@@ -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<SystemRecord>("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<string, SystemRecord>, 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<SystemRecord[]> {
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<Record<string, SystemRecord>>) {
const key = store === $allSystemsByName ? system.name : system.id
store.setKey(key, undefined as any)
}
/** Action functions for subscription */
const actionFns: Record<string, (system: SystemRecord) => 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?.())

View File

@@ -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<T extends (...args: any[]) => any>(func: T, wait: numbe
}
}
/* returns the name of a system from its id */
export const getSystemNameFromId = (() => {
const cache = new Map<string, string>()
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<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {
let done = false

View File

@@ -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 <h1 className="text-3xl text-center my-14">404</h1>
} else if (page.route === "home") {