- {activeAlerts.map((alert) => {
- const info = alertInfo[alert.name as keyof typeof alertInfo]
- return (
-
-
-
- {alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
-
-
- {alert.name === "Status" ? (
- Connection is down
- ) : (
-
- Exceeds {alert.value}
- {info.unit} in last
-
- )}
-
-
-
- )
- })}
+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 (
+
+
+
+
+ Active Alerts
+
- )}
-
-
- )
-})
+
+
+ {activeAlerts.length > 0 && (
+
+ {activeAlerts.map((alert) => {
+ const info = alertInfo[alert.name as keyof typeof alertInfo]
+ return (
+
+
+
+ {getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
+
+
+ {alert.name === "Status" ? (
+ Connection is down
+ ) : (
+
+ Exceeds {alert.value}
+ {info.unit} in last
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+ }, [alertsKey.join("")])
+}
diff --git a/beszel/site/src/components/routes/settings/notifications.tsx b/beszel/site/src/components/routes/settings/notifications.tsx
index ae4010b..29c3ca3 100644
--- a/beszel/site/src/components/routes/settings/notifications.tsx
+++ b/beszel/site/src/components/routes/settings/notifications.tsx
@@ -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`,
diff --git a/beszel/site/src/lib/stores.ts b/beszel/site/src/lib/stores.ts
index 02118d6..97da3b8 100644
--- a/beszel/site/src/lib/stores.ts
+++ b/beszel/site/src/lib/stores.ts
@@ -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
([])
-/** List of alert records */
-export const $alerts = atom([] as AlertRecord[])
+/** Map of alert records by system id and alert name */
+export const $alerts = map({})
/** SSH public key */
export const $publicKey = atom("")
/** Chart time period */
-export const $chartTime = atom("1h") as PreinitializedWritableAtom
+export const $chartTime = atom("1h")
/** Whether to display average or max chart values */
export const $maxValues = atom(false)
@@ -43,10 +43,8 @@ export const $userSettings = map({
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("")
diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts
index 7123b0f..d80aa45 100644
--- a/beszel/site/src/lib/utils.ts
+++ b/beszel/site/src/lib/utils.ts
@@ -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({ 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 = {
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 any>(func: T, wait: number): (...args: Parameters) => void {
+ let timeout: ReturnType
+ return (...args: Parameters) => {
+ clearTimeout(timeout)
+ timeout = setTimeout(() => func(...args), wait)
+ }
+}
+
+/* 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
+ }
+})()
+
+// TODO: reorganize this utils file into more specific files
+/** Helper to manage user alerts */
+export const alertManager = (() => {
+ const collection = pb.collection("alerts")
+
+ /** Fields to fetch from alerts collection */
+ const fields = "id,name,system,value,min,triggered"
+
+ /** Fetch alerts from collection */
+ async function fetchAlerts(): Promise {
+ return await collection.getFullList({ 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[]) {
+ 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>()
+ let timeout: ReturnType
+
+ return (data: RecordSubscription) => {
+ const { record } = data
+ batch.set(`${record.system}${record.name}`, data)
+ clearTimeout(timeout!)
+ timeout = setTimeout(() => {
+ const groups = { create: [], update: [], delete: [] } as Record
+ 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)
+ },
+ }
+})()
diff --git a/beszel/site/src/main.tsx b/beszel/site/src/main.tsx
index 57b5c8f..faef365 100644
--- a/beszel/site/src/main.tsx
+++ b/beszel/site/src/main.tsx
@@ -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")
}, [])
diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts
index 6639daf..d9ec2eb 100644
--- a/beszel/site/src/types.d.ts
+++ b/beszel/site/src/types.d.ts
@@ -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>