remove batch api in favor of custom endpoint for alerts management (#1039, #1023)

This commit is contained in:
henrygd
2025-08-19 20:56:12 -04:00
parent 37c6b920f9
commit 7ba1f366ba
13 changed files with 912 additions and 434 deletions

View File

@@ -1,31 +1,19 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react"
import { $alerts } from "@/lib/stores"
import {
Dialog,
DialogTrigger,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { alertInfo, cn } from "@/lib/utils"
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog"
import { BellIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types"
import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router"
import { SystemRecord } from "@/types"
import { AlertDialogContent } from "./alerts-dialog"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [opened, setOpened] = useState(false)
const alerts = useStore($alerts)
const hasAlert = alerts.some((alert) => alert.system === system.id)
const hasSystemAlert = alerts[system.id]?.size > 0
return useMemo(
() => (
@@ -34,7 +22,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
"fill-primary": hasAlert,
"fill-primary": hasSystemAlert,
})}
/>
</Button>
@@ -44,7 +32,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
</DialogContent>
</Dialog>
),
[opened, hasAlert]
[opened, hasSystemAlert]
)
// return useMemo(
@@ -67,87 +55,3 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
// [opened, hasAlert]
// )
})
function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
/* key to prevent re-rendering */
const alertsSignature: string[] = []
const systemAlerts = alerts.filter((alert) => {
if (alert.system === system.id) {
alertsSignature.push(alert.name, alert.min, alert.value)
return true
}
return false
}) as AlertRecord[]
return useMemo(() => {
const data = Object.keys(alertInfo).map((name) => {
const alert = alertInfo[name as keyof typeof alertInfo]
return {
name: name as keyof typeof alertInfo,
alert,
system,
}
})
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
<Trans>Alerts</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
See{" "}
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="system">
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" />
{system.name}
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Systems</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="system">
<div className="grid gap-3">
{data.map((d) => (
<SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
))}
</div>
</TabsContent>
<TabsContent value="global">
<label
htmlFor="ovw"
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
>
<Checkbox
id="ovw"
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
/>
<Trans>Overwrite existing alerts</Trans>
</label>
<div className="grid gap-3">
{data.map((d) => (
<SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
))}
</div>
</TabsContent>
</Tabs>
</>
)
}, [alertsSignature.join(""), overwriteExisting])
}

View File

@@ -1,226 +1,208 @@
import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro"
import { $alerts, $systems, pb } from "@/lib/stores"
import { alertInfo, cn } from "@/lib/utils"
import { alertInfo, cn, debounce } from "@/lib/utils"
import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, Suspense, useMemo, useState } from "react"
import { toast } from "../ui/use-toast"
import { BatchService } from "pocketbase"
import { getSemaphore } from "@henrygd/semaphore"
interface AlertData {
checked?: boolean
val?: number
min?: number
updateAlert?: (checked: boolean, value: number, min: number) => void
name: keyof typeof alertInfo
alert: AlertInfo
system: SystemRecord
}
import { lazy, memo, Suspense, useMemo, useState } from "react"
import { toast } from "@/components/ui/use-toast"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { ServerIcon, GlobeIcon } from "lucide-react"
import { $router, Link } from "@/components/router"
import { DialogHeader } from "@/components/ui/dialog"
const Slider = lazy(() => import("@/components/ui/slider"))
const failedUpdateToast = () =>
const endpoint = "/api/beszel/user-alerts"
const alertDebounce = 100
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
const failedUpdateToast = (error: unknown) => {
console.error(error)
toast({
title: t`Failed to update alert`,
description: t`Please check logs for more details.`,
variant: "destructive",
})
export function SystemAlert({
system,
systemAlerts,
data,
}: {
system: SystemRecord
systemAlerts: AlertRecord[]
data: AlertData
}) {
const alert = systemAlerts.find((alert) => alert.name === data.name)
data.updateAlert = async (checked: boolean, value: number, min: number) => {
try {
if (alert && !checked) {
await pb.collection("alerts").delete(alert.id)
} else if (alert && checked) {
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
} else if (checked) {
pb.collection("alerts").create({
system: system.id,
user: pb.authStore.record!.id,
name: data.name,
value: value,
min: min,
})
}
} catch (e) {
failedUpdateToast()
}
}
if (alert) {
data.checked = true
data.val = alert.value
data.min = alert.min || 1
}
return <AlertContent data={data} />
}
export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
data.checked = false
data.val = data.min = 0
/** Create or update alerts for a given name and systems */
const upsertAlerts = debounce(
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "POST",
// overwrite is always true because we've done filtering client side
body: { name, value, min, systems, overwrite: true },
})
} catch (error) {
failedUpdateToast(error)
}
},
alertDebounce
)
// set of system ids that have an alert for this name when the component is mounted
const existingAlertsSystems = useMemo(() => {
const map = new Set<string>()
const alerts = $alerts.get()
for (const alert of alerts) {
if (alert.name === data.name) {
map.add(alert.system)
/** Delete alerts for a given name and systems */
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems },
})
} catch (error) {
failedUpdateToast(error)
}
}, alertDebounce)
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("system")
const systemAlerts = alerts[system.id] ?? new Map()
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
// current alerts, it will only be updated when first checked, then won't be updated because
// after that it exists.
const alertsWhenGlobalSelected = useMemo(() => {
return currentTab === "global" ? structuredClone(alerts) : alerts
}, [currentTab])
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
<Trans>Alerts</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
See{" "}
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" />
{system.name}
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Systems</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="system">
<div className="grid gap-3">
{alertKeys.map((name) => (
<AlertContent
key={name}
alertKey={name}
data={alertInfo[name as keyof typeof alertInfo]}
alert={systemAlerts.get(name)}
system={system}
/>
))}
</div>
</TabsContent>
<TabsContent value="global">
<label
htmlFor="ovw"
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
>
<Checkbox
id="ovw"
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
/>
<Trans>Overwrite existing alerts</Trans>
</label>
<div className="grid gap-3">
{alertKeys.map((name) => (
<AlertContent
key={name}
alertKey={name}
system={system}
alert={systemAlerts.get(name)}
data={alertInfo[name as keyof typeof alertInfo]}
global={true}
overwriteExisting={!!overwriteExisting}
initialAlertsState={alertsWhenGlobalSelected}
/>
))}
</div>
</TabsContent>
</Tabs>
</>
)
})
export function AlertContent({
alertKey,
data: alertData,
system,
alert,
global = false,
overwriteExisting = false,
initialAlertsState = {},
}: {
alertKey: string
data: AlertInfo
system: SystemRecord
alert?: AlertRecord
global?: boolean
overwriteExisting?: boolean
initialAlertsState?: Record<string, Map<string, AlertRecord>>
}) {
const { name } = alertData
const singleDescription = alertData.singleDesc?.()
const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || 10)
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
const Icon = alertData.icon
/** Get system ids to update */
function getSystemIds(): string[] {
// if not global, update only the current system
if (!global) {
return [system.id]
}
// if global, update all systems when overwriteExisting is true
// update only systems without an existing alert when overwriteExisting is false
const allSystems = $systems.get()
const systemIds: string[] = []
for (const system of allSystems) {
if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
systemIds.push(system.id)
}
}
return map
}, [])
return systemIds
}
data.updateAlert = async (checked: boolean, value: number, min: number) => {
const sem = getSemaphore("alerts")
await sem.acquire()
try {
// if another update is waiting behind, don't start this one
if (sem.size() > 1) {
return
}
const recordData: Partial<AlertRecord> = {
function sendUpsert(min: number, value: number) {
const systems = getSystemIds()
systems.length &&
upsertAlerts({
name: alertKey,
value,
min,
triggered: false,
}
const batch = batchWrapper("alerts", 25)
const systems = $systems.get()
const currentAlerts = $alerts.get()
// map of current alerts with this name right now by system id
const currentAlertsSystems = new Map<string, AlertRecord>()
for (const alert of currentAlerts) {
if (alert.name === data.name) {
currentAlertsSystems.set(alert.system, alert)
}
}
if (overwrite) {
existingAlertsSystems.clear()
}
const processSystem = async (system: SystemRecord): Promise<void> => {
const existingAlert = existingAlertsSystems.has(system.id)
if (!overwrite && existingAlert) {
return
}
const currentAlert = currentAlertsSystems.get(system.id)
// delete existing alert if unchecked
if (!checked && currentAlert) {
return batch.remove(currentAlert.id)
}
if (checked && currentAlert) {
// update existing alert if checked
return batch.update(currentAlert.id, recordData)
}
if (checked) {
// create new alert if checked and not existing
return batch.create({
system: system.id,
user: pb.authStore.record!.id,
name: data.name,
...recordData,
})
}
}
// make sure current system is updated in the first batch
await processSystem(data.system)
for (const system of systems) {
if (system.id === data.system.id) {
continue
}
if (sem.size() > 1) {
return
}
await processSystem(system)
}
await batch.send()
} finally {
sem.release()
}
systems,
})
}
return <AlertContent data={data} />
}
/**
* Creates a wrapper for performing batch operations on a specified collection.
*/
function batchWrapper(collection: string, batchSize: number) {
let batch: BatchService | undefined
let count = 0
const create = async <T extends Record<string, any>>(options: T) => {
batch ||= pb.createBatch()
batch.collection(collection).create(options)
if (++count >= batchSize) {
await send()
}
}
const update = async <T extends Record<string, any>>(id: string, data: T) => {
batch ||= pb.createBatch()
batch.collection(collection).update(id, data)
if (++count >= batchSize) {
await send()
}
}
const remove = async (id: string) => {
batch ||= pb.createBatch()
batch.collection(collection).delete(id)
if (++count >= batchSize) {
await send()
}
}
const send = async () => {
if (count) {
await batch?.send({ requestKey: null })
batch = undefined
count = 0
}
}
return {
update,
remove,
send,
create,
}
}
function AlertContent({ data }: { data: AlertData }) {
const { name } = data
const singleDescription = data.alert.singleDesc?.()
const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || 10)
const [value, setValue] = useState(data.val || (singleDescription ? 0 : data.alert.start ?? 80))
const Icon = alertInfo[name].icon
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
@@ -231,16 +213,28 @@ function AlertContent({ data }: { data: AlertData }) {
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
</p>
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
</div>
<Switch
id={`s${name}`}
checked={checked}
onCheckedChange={(newChecked) => {
setChecked(newChecked)
data.updateAlert?.(newChecked, value, min)
if (newChecked) {
// if alert checked, create or update alert
sendUpsert(min, value)
} else {
// if unchecked, delete alert (unless global and overwriteExisting is false)
deleteAlerts({ name: alertKey, systems: getSystemIds() })
// when force deleting all alerts of a type, also remove them from initialAlertsState
if (overwriteExisting) {
for (const curAlerts of Object.values(initialAlertsState)) {
curAlerts.delete(alertKey)
}
}
}
}}
/>
</label>
@@ -254,7 +248,7 @@ function AlertContent({ data }: { data: AlertData }) {
Average exceeds{" "}
<strong className="text-foreground">
{value}
{data.alert.unit}
{alertData.unit}
</strong>
</Trans>
</p>
@@ -262,15 +256,11 @@ function AlertContent({ data }: { data: AlertData }) {
<Slider
aria-labelledby={`v${name}`}
defaultValue={[value]}
onValueCommit={(val) => {
data.updateAlert?.(true, val[0], min)
}}
onValueChange={(val) => {
setValue(val[0])
}}
step={data.alert.step ?? 1}
min={data.alert.min ?? 1}
max={alertInfo[name].max ?? 99}
onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
/>
</div>
</div>
@@ -292,12 +282,8 @@ function AlertContent({ data }: { data: AlertData }) {
<Slider
aria-labelledby={`v${name}`}
defaultValue={[min]}
onValueCommit={(min) => {
data.updateAlert?.(true, value, min[0])
}}
onValueChange={(val) => {
setMin(val[0])
}}
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>

View File

@@ -4,7 +4,7 @@ import { $alerts, $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
import { alertInfo, getSystemNameFromId, updateRecordList, updateSystemList } from "@/lib/utils"
import { AlertRecord, SystemRecord } from "@/types"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { $router, Link } from "../router"
@@ -14,26 +14,8 @@ import { getPagePath } from "@nanostores/router"
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
export const Home = memo(() => {
const alerts = useStore($alerts)
const systems = useStore($systems)
const { t } = useLingui()
/* key to prevent re-rendering of active alerts */
const alertsKey: string[] = []
const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo
if (!active) {
return false
}
alert.sysname = systems.find((system) => system.id === alert.system)?.name
alertsKey.push(alert.id)
return true
})
return activeAlerts
}, [systems, alerts])
useEffect(() => {
document.title = t`Dashboard` + " / Beszel"
}, [t])
@@ -46,20 +28,15 @@ export const Home = memo(() => {
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
updateRecordList(e, $systems)
})
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
updateRecordList(e, $alerts)
})
return () => {
pb.collection("systems").unsubscribe("*")
// pb.collection('alerts').unsubscribe('*')
}
}, [])
return useMemo(
() => (
<>
{/* show active alerts */}
{activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
<ActiveAlerts />
<Suspense>
<SystemsTable />
</Suspense>
@@ -83,55 +60,79 @@ export const Home = memo(() => {
</div>
</>
),
[alertsKey.join("")]
[]
)
})
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
return (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
const ActiveAlerts = () => {
const alerts = useStore($alerts)
const { activeAlerts, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = []
// key to prevent re-rendering if alerts change but active alerts didn't
const alertsKey: string[] = []
for (const systemId of Object.keys(alerts)) {
for (const alert of alerts[systemId].values()) {
if (alert.triggered && alert.name in alertInfo) {
activeAlerts.push(alert)
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
}
}
}
return { activeAlerts, alertsKey }
}, [alerts])
return useMemo(() => {
if (activeAlerts.length === 0) {
return null
}
return (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
)}
</CardContent>
</Card>
)
})
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
}, [alertsKey.join("")])
}

View File

@@ -178,7 +178,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
const sendTestNotification = async () => {
setIsLoading(true)
const res = await pb.send("/api/beszel/send-test-notification", { url })
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
if ("err" in res && !res.err) {
toast({
title: t`Test notification sent`,

View File

@@ -1,6 +1,6 @@
import PocketBase from "pocketbase"
import { atom, map, PreinitializedWritableAtom } from "nanostores"
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
import { atom, map } from "nanostores"
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import { basePath } from "@/components/router"
import { Unit } from "./enums"
@@ -11,16 +11,16 @@ export const pb = new PocketBase(basePath)
export const $authenticated = atom(pb.authStore.isValid)
/** List of system records */
export const $systems = atom([] as SystemRecord[])
export const $systems = atom<SystemRecord[]>([])
/** List of alert records */
export const $alerts = atom([] as AlertRecord[])
/** Map of alert records by system id and alert name */
export const $alerts = map<AlertMap>({})
/** SSH public key */
export const $publicKey = atom("")
/** Chart time period */
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
export const $chartTime = atom<ChartTimes>("1h")
/** Whether to display average or max chart values */
export const $maxValues = atom(false)
@@ -43,10 +43,8 @@ export const $userSettings = map<UserSettings>({
unitNet: Unit.Bytes,
unitTemp: Unit.Celsius,
})
// update local storage on change
$userSettings.subscribe((value) => {
$chartTime.set(value.chartTime)
})
// update chart time on change
$userSettings.subscribe((value) => $chartTime.set(value.chartTime))
/** Container chart filter */
export const $containerFilter = atom("")

View File

@@ -84,21 +84,13 @@ export const updateSystemList = (() => {
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() {
$systems.set([])
$alerts.set([])
$alerts.set({})
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear()
pb.realtime.unsubscribe()
}
export const updateAlerts = () => {
pb.collection("alerts")
.getFullList<AlertRecord>({ fields: "id,name,system,value,min,triggered", sort: "updated" })
.then((records) => {
$alerts.set(records)
})
}
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "numeric",
@@ -439,7 +431,7 @@ export const alertInfo: Record<string, AlertInfo> = {
step: 0.1,
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
},
}
} as const
/**
* Retuns value of system host, truncating full path if socket.
@@ -513,3 +505,103 @@ export function getMeterState(value: number): MeterState {
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
}
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
/* 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
}
})()
// TODO: reorganize this utils file into more specific files
/** Helper to manage user alerts */
export const alertManager = (() => {
const collection = pb.collection<AlertRecord>("alerts")
/** Fields to fetch from alerts collection */
const fields = "id,name,system,value,min,triggered"
/** Fetch alerts from collection */
async function fetchAlerts(): Promise<AlertRecord[]> {
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
}
/** Format alerts into a map of system id to alert name to alert record */
function add(alerts: AlertRecord[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId] ?? new Map()
const newAlerts = new Map(systemAlerts)
newAlerts.set(alert.name, alert)
$alerts.setKey(systemId, newAlerts)
}
}
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
for (const alert of alerts) {
const systemId = alert.system
const systemAlerts = $alerts.get()[systemId]
const newAlerts = new Map(systemAlerts)
newAlerts.delete(alert.name)
$alerts.setKey(systemId, newAlerts)
}
}
const actionFns = {
create: add,
update: add,
delete: remove,
}
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
const batchUpdate = (() => {
const batch = new Map<string, RecordSubscription<AlertRecord>>()
let timeout: ReturnType<typeof setTimeout>
return (data: RecordSubscription<AlertRecord>) => {
const { record } = data
batch.set(`${record.system}${record.name}`, data)
clearTimeout(timeout!)
timeout = setTimeout(() => {
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
for (const { action, record } of batch.values()) {
groups[action]?.push(record)
}
for (const key in groups) {
if (groups[key].length) {
actionFns[key as keyof typeof actionFns]?.(groups[key])
}
}
batch.clear()
}, 50)
}
})()
collection.subscribe("*", batchUpdate, { fields })
return {
/** Add alerts to store */
add,
/** Remove alerts from store */
remove,
/** Refresh alerts with latest data from hub */
async refresh() {
const records = await fetchAlerts()
add(records)
},
}
})()

View File

@@ -6,7 +6,7 @@ import { Home } from "./components/routes/home.tsx"
import { ThemeProvider } from "./components/theme-provider.tsx"
import { DirectionProvider } from "@radix-ui/react-direction"
import { $authenticated, $systems, pb, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts"
import { updateUserSettings, updateFavicon, updateSystemList, alertManager } from "./lib/utils.ts"
import { useStore } from "@nanostores/react"
import { Toaster } from "./components/ui/toaster.tsx"
import { $router } from "./components/router.tsx"
@@ -38,7 +38,7 @@ const App = memo(() => {
// get servers / alerts / settings
updateUserSettings()
// get alerts after system list is loaded
updateSystemList().then(updateAlerts)
updateSystemList().then(alertManager.refresh)
return () => updateFavicon("favicon.svg")
}, [])

View File

@@ -196,7 +196,8 @@ export interface AlertRecord extends RecordModel {
system: string
name: string
triggered: boolean
sysname?: string
value: number
min: number
// user: string
}
@@ -268,3 +269,5 @@ interface AlertInfo {
/** Single value description (when there's only one value, like status) */
singleDesc?: () => string
}
export type AlertMap = Record<string, Map<string, AlertRecord>>