mirror of
https://github.com/fankes/beszel.git
synced 2025-10-18 17:29:28 +08:00
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:
@@ -1,12 +1,10 @@
|
|||||||
import { Suspense, memo, useEffect, useMemo } from "react"
|
import { Suspense, memo, useEffect, useMemo } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
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 { useStore } from "@nanostores/react"
|
||||||
import { GithubIcon } from "lucide-react"
|
import { GithubIcon } from "lucide-react"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { getSystemNameFromId } from "@/lib/utils"
|
import { AlertRecord } from "@/types"
|
||||||
import { pb, updateRecordList, updateSystemList } from "@/lib/api"
|
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
||||||
@@ -14,8 +12,6 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
import { alertInfo } from "@/lib/alerts"
|
import { alertInfo } from "@/lib/alerts"
|
||||||
import SystemsTable from "@/components/systems-table/systems-table"
|
import SystemsTable from "@/components/systems-table/systems-table"
|
||||||
|
|
||||||
// const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
|
||||||
|
|
||||||
export default memo(function () {
|
export default memo(function () {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
@@ -23,19 +19,6 @@ export default memo(function () {
|
|||||||
document.title = t`Dashboard` + " / Beszel"
|
document.title = t`Dashboard` + " / Beszel"
|
||||||
}, [t])
|
}, [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(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
@@ -69,6 +52,7 @@ export default memo(function () {
|
|||||||
|
|
||||||
const ActiveAlerts = () => {
|
const ActiveAlerts = () => {
|
||||||
const alerts = useStore($alerts)
|
const alerts = useStore($alerts)
|
||||||
|
const systems = useStore($allSystemsById)
|
||||||
|
|
||||||
const { activeAlerts, alertsKey } = useMemo(() => {
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
const activeAlerts: AlertRecord[] = []
|
const activeAlerts: AlertRecord[] = []
|
||||||
@@ -112,7 +96,7 @@ const ActiveAlerts = () => {
|
|||||||
>
|
>
|
||||||
<info.icon className="h-4 w-4" />
|
<info.icon className="h-4 w-4" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
|
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{alert.name === "Status" ? (
|
{alert.name === "Status" ? (
|
||||||
@@ -125,7 +109,7 @@ const ActiveAlerts = () => {
|
|||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<Link
|
<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"
|
className="absolute inset-0 w-full h-full"
|
||||||
aria-label="View system"
|
aria-label="View system"
|
||||||
></Link>
|
></Link>
|
||||||
|
@@ -8,6 +8,7 @@ import {
|
|||||||
$direction,
|
$direction,
|
||||||
$maxValues,
|
$maxValues,
|
||||||
$temperatureFilter,
|
$temperatureFilter,
|
||||||
|
$allSystemsByName,
|
||||||
} from "@/lib/stores"
|
} from "@/lib/stores"
|
||||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums"
|
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 TemperatureChart from "@/components/charts/temperature-chart"
|
||||||
import GpuPowerChart from "@/components/charts/gpu-power-chart"
|
import GpuPowerChart from "@/components/charts/gpu-power-chart"
|
||||||
import LoadAverageChart from "@/components/charts/load-average-chart"
|
import LoadAverageChart from "@/components/charts/load-average-chart"
|
||||||
|
import { subscribeKeys } from "nanostores"
|
||||||
|
|
||||||
const cache = new Map<string, any>()
|
const cache = new Map<string, any>()
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ function dockerOrPodman(str: string, system: SystemRecord) {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SystemDetail({ name }: { name: string }) {
|
export default memo(function SystemDetail({ name }: { name: string }) {
|
||||||
const direction = useStore($direction)
|
const direction = useStore($direction)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -149,36 +151,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
}, [name])
|
}, [name])
|
||||||
|
|
||||||
// function resetCharts() {
|
// find matching system and update when it changes
|
||||||
// setSystemStats([])
|
|
||||||
// setContainerData([])
|
|
||||||
// }
|
|
||||||
|
|
||||||
// useEffect(resetCharts, [chartTime])
|
|
||||||
|
|
||||||
// find matching system
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (system.id && system.name === name) {
|
return subscribeKeys($allSystemsByName, [name], (newSystems) => {
|
||||||
return
|
const sys = newSystems[name]
|
||||||
}
|
sys?.id && setSystem(sys)
|
||||||
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 () => {
|
}, [name])
|
||||||
pb.collection("systems").unsubscribe(system.id)
|
|
||||||
}
|
|
||||||
}, [system.id])
|
|
||||||
|
|
||||||
const chartData: ChartData = useMemo(() => {
|
const chartData: ChartData = useMemo(() => {
|
||||||
const lastCreated = Math.max(
|
const lastCreated = Math.max(
|
||||||
@@ -835,7 +814,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||||
const containerFilter = useStore(store)
|
const containerFilter = useStore(store)
|
||||||
|
@@ -36,9 +36,9 @@ import {
|
|||||||
FilterIcon,
|
FilterIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "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 { useStore } from "@nanostores/react"
|
||||||
import { cn, runOnce, useLocalStorage } from "@/lib/utils"
|
import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
import { useLingui, Trans } from "@lingui/react/macro"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
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"
|
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | "up" | "down" | "paused"
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
|
|
||||||
const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx"))
|
const preloadSystemDetail = runOnce(() => import("@/components/routes/system.tsx"))
|
||||||
|
|
||||||
export default function SystemsTable() {
|
export default function SystemsTable() {
|
||||||
const data = useStore($systems)
|
const data = useStore($systems)
|
||||||
|
const downSystems = $downSystems.get()
|
||||||
|
const upSystems = $upSystems.get()
|
||||||
|
const pausedSystems = $pausedSystems.get()
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
const [filter, setFilter] = useState<string>()
|
const [filter, setFilter] = useState<string>()
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
@@ -74,7 +77,13 @@ export default function SystemsTable() {
|
|||||||
if (statusFilter === "all") {
|
if (statusFilter === "all") {
|
||||||
return data
|
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])
|
}, [data, statusFilter])
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
|
||||||
@@ -106,7 +115,6 @@ export default function SystemsTable() {
|
|||||||
columnVisibility,
|
columnVisibility,
|
||||||
},
|
},
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
// sortDescFirst: true,
|
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
minSize: 0,
|
minSize: 0,
|
||||||
@@ -118,18 +126,22 @@ export default function SystemsTable() {
|
|||||||
const rows = table.getRowModel().rows
|
const rows = table.getRowModel().rows
|
||||||
const columns = table.getAllColumns()
|
const columns = table.getAllColumns()
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
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
|
// TODO: hiding temp then gpu messes up table headers
|
||||||
const CardHead = useMemo(() => {
|
const CardHead = useMemo(() => {
|
||||||
return (
|
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="grid md:flex gap-5 w-full items-end">
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2.5">
|
<CardTitle className="mb-2">
|
||||||
<Trans>All Systems</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="flex">
|
<CardDescription className="flex">
|
||||||
<Trans>Click on a system to view information - {runningRecords} / {totalRecords}</Trans>
|
<Trans>Click on a system to view more information.</Trans>
|
||||||
<p className={"ml-2 text-" + (runningRecords === totalRecords ? "emerald" : "red") + "-600"}>Online</p>
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,13 +193,13 @@ export default function SystemsTable() {
|
|||||||
<Trans>All Systems</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
|
||||||
<Trans>Up</Trans>
|
<Trans>Up ({upSystemsLength})</Trans>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
|
||||||
<Trans>Down</Trans>
|
<Trans>Down ({downSystemsLength})</Trans>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
|
<DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
|
||||||
<Trans>Paused</Trans>
|
<Trans>Paused ({pausedSystemsLength})</Trans>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
</DropdownMenuRadioGroup>
|
</DropdownMenuRadioGroup>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,7 +270,16 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
)
|
)
|
||||||
}, [visibleColumns.length, sorting, viewMode, locale, statusFilter, runningRecords, totalRecords])
|
}, [
|
||||||
|
visibleColumns.length,
|
||||||
|
sorting,
|
||||||
|
viewMode,
|
||||||
|
locale,
|
||||||
|
statusFilter,
|
||||||
|
upSystemsLength,
|
||||||
|
downSystemsLength,
|
||||||
|
pausedSystemsLength,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
@@ -1,10 +1,8 @@
|
|||||||
import { ChartTimes, SystemRecord, UserSettings } from "@/types"
|
import { ChartTimes, UserSettings } from "@/types"
|
||||||
import { $alerts, $longestSystemNameLen, $systems, $userSettings } from "./stores"
|
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { chartTimeData } from "./utils"
|
import { chartTimeData } from "./utils"
|
||||||
import { WritableAtom } from "nanostores"
|
|
||||||
import { RecordModel, RecordSubscription } from "pocketbase"
|
|
||||||
import PocketBase from "pocketbase"
|
import PocketBase from "pocketbase"
|
||||||
import { basePath } from "@/components/router"
|
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 isAdmin = () => pb.authStore.record?.role === "admin"
|
||||||
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
|
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
|
||||||
|
|
||||||
const verifyAuth = () => {
|
export const verifyAuth = () => {
|
||||||
pb.collection("users")
|
pb.collection("users")
|
||||||
.authRefresh()
|
.authRefresh()
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -29,7 +27,7 @@ const verifyAuth = () => {
|
|||||||
|
|
||||||
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
||||||
export async function logOut() {
|
export async function logOut() {
|
||||||
$systems.set([])
|
$allSystemsByName.set({})
|
||||||
$alerts.set({})
|
$alerts.set({})
|
||||||
$userSettings.set({} as UserSettings)
|
$userSettings.set({} as UserSettings)
|
||||||
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
||||||
@@ -54,74 +52,6 @@ export async function updateUserSettings() {
|
|||||||
console.error("create settings", e)
|
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) {
|
export function getPbTimestamp(timeString: ChartTimes, d?: Date) {
|
||||||
d ||= chartTimeData[timeString].getOffset(new Date())
|
d ||= chartTimeData[timeString].getOffset(new Date())
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { atom, map } from "nanostores"
|
import { atom, computed, map, ReadableAtom } from "nanostores"
|
||||||
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
||||||
import { Unit } from "./enums"
|
import { Unit } from "./enums"
|
||||||
import { pb } from "./api"
|
import { pb } from "./api"
|
||||||
@@ -6,8 +6,18 @@ import { pb } from "./api"
|
|||||||
/** Store if user is authenticated */
|
/** Store if user is authenticated */
|
||||||
export const $authenticated = atom(pb.authStore.isValid)
|
export const $authenticated = atom(pb.authStore.isValid)
|
||||||
|
|
||||||
/** List of system records */
|
/** Map of system records by name */
|
||||||
export const $systems = atom<SystemRecord[]>([])
|
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 */
|
/** Map of alert records by system id and alert name */
|
||||||
export const $alerts = map<AlertMap>({})
|
export const $alerts = map<AlertMap>({})
|
||||||
|
161
beszel/site/src/lib/systemsManager.ts
Normal file
161
beszel/site/src/lib/systemsManager.ts
Normal 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?.())
|
@@ -2,13 +2,17 @@ import { t } from "@lingui/core/macro"
|
|||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
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 type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
||||||
import { timeDay, timeHour } from "d3-time"
|
import { timeDay, timeHour } from "d3-time"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { MeterState, Unit } from "./enums"
|
import { MeterState, Unit } from "./enums"
|
||||||
import { prependBasePath } from "@/components/router"
|
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[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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 */
|
/** Run a function only once */
|
||||||
export function runOnce<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {
|
export function runOnce<T extends (...args: any[]) => any>(fn: T): (...args: Parameters<T>) => ReturnType<T> {
|
||||||
let done = false
|
let done = false
|
||||||
|
@@ -4,17 +4,16 @@ import { Suspense, lazy, memo, useEffect } from "react"
|
|||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import { ThemeProvider } from "./components/theme-provider.tsx"
|
import { ThemeProvider } from "./components/theme-provider.tsx"
|
||||||
import { DirectionProvider } from "@radix-ui/react-direction"
|
import { DirectionProvider } from "@radix-ui/react-direction"
|
||||||
import { $authenticated, $systems, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
|
import { $authenticated, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
|
||||||
import { pb, updateSystemList, updateUserSettings } from "./lib/api.ts"
|
import { pb, updateUserSettings } from "./lib/api.ts"
|
||||||
|
import * as systemsManager from "./lib/systemsManager.ts"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { Toaster } from "./components/ui/toaster.tsx"
|
import { Toaster } from "./components/ui/toaster.tsx"
|
||||||
import { $router } from "./components/router.tsx"
|
import { $router } from "./components/router.tsx"
|
||||||
import { updateFavicon } from "@/lib/utils"
|
|
||||||
import Navbar from "./components/navbar.tsx"
|
import Navbar from "./components/navbar.tsx"
|
||||||
import { I18nProvider } from "@lingui/react"
|
import { I18nProvider } from "@lingui/react"
|
||||||
import { i18n } from "@lingui/core"
|
import { i18n } from "@lingui/core"
|
||||||
import { getLocale, dynamicActivate } from "./lib/i18n"
|
import { getLocale, dynamicActivate } from "./lib/i18n"
|
||||||
import { SystemStatus } from "./lib/enums"
|
|
||||||
import { alertManager } from "./lib/alerts"
|
import { alertManager } from "./lib/alerts"
|
||||||
import Settings from "./components/routes/settings/layout.tsx"
|
import Settings from "./components/routes/settings/layout.tsx"
|
||||||
|
|
||||||
@@ -25,8 +24,6 @@ const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.
|
|||||||
|
|
||||||
const App = memo(() => {
|
const App = memo(() => {
|
||||||
const page = useStore($router)
|
const page = useStore($router)
|
||||||
const authenticated = useStore($authenticated)
|
|
||||||
const systems = useStore($systems)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// change auth store on auth change
|
// change auth store on auth change
|
||||||
@@ -37,40 +34,26 @@ const App = memo(() => {
|
|||||||
pb.send("/api/beszel/getkey", {}).then((data) => {
|
pb.send("/api/beszel/getkey", {}).then((data) => {
|
||||||
$publicKey.set(data.key)
|
$publicKey.set(data.key)
|
||||||
})
|
})
|
||||||
// get servers / alerts / settings
|
// get user settings
|
||||||
updateUserSettings()
|
updateUserSettings()
|
||||||
// need to get system list before alerts
|
// need to get system list before alerts
|
||||||
updateSystemList()
|
systemsManager.init()
|
||||||
// get alerts
|
systemsManager
|
||||||
|
// get current systems list
|
||||||
|
.refresh()
|
||||||
|
// subscribe to new system updates
|
||||||
|
.then(systemsManager.subscribe)
|
||||||
|
// get current alerts
|
||||||
.then(alertManager.refresh)
|
.then(alertManager.refresh)
|
||||||
// subscribe to new alert updates
|
// subscribe to new alert updates
|
||||||
.then(alertManager.subscribe)
|
.then(alertManager.subscribe)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
updateFavicon("favicon.svg")
|
// updateFavicon("favicon.svg")
|
||||||
alertManager.unsubscribe()
|
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) {
|
if (!page) {
|
||||||
return <h1 className="text-3xl text-center my-14">404</h1>
|
return <h1 className="text-3xl text-center my-14">404</h1>
|
||||||
} else if (page.route === "home") {
|
} else if (page.route === "home") {
|
||||||
|
Reference in New Issue
Block a user