diff --git a/beszel/internal/alerts/alerts.go b/beszel/internal/alerts/alerts.go index e796a46..807b9cc 100644 --- a/beszel/internal/alerts/alerts.go +++ b/beszel/internal/alerts/alerts.go @@ -10,7 +10,6 @@ import ( "github.com/nicholas-fedor/shoutrrr" "github.com/pocketbase/dbx" - "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/mailer" ) @@ -206,16 +205,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, } func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error { - info, _ := e.RequestInfo() - if info.Auth == nil { - return apis.NewForbiddenError("Forbidden", nil) + var data struct { + URL string `json:"url"` } - url := e.Request.URL.Query().Get("url") - // log.Println("url", url) - if url == "" { - return e.JSON(200, map[string]string{"err": "URL is required"}) + err := e.BindBody(&data) + if err != nil || data.URL == "" { + return e.BadRequestError("URL is required", err) } - err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel") + err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel") if err != nil { return e.JSON(200, map[string]string{"err": err.Error()}) } diff --git a/beszel/internal/alerts/alerts_api.go b/beszel/internal/alerts/alerts_api.go new file mode 100644 index 0000000..972f01b --- /dev/null +++ b/beszel/internal/alerts/alerts_api.go @@ -0,0 +1,119 @@ +package alerts + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" +) + +// UpsertUserAlerts handles API request to create or update alerts for a user +// across multiple systems (POST /api/beszel/user-alerts) +func UpsertUserAlerts(e *core.RequestEvent) error { + userID := e.Auth.Id + + reqData := struct { + Min uint8 `json:"min"` + Value float64 `json:"value"` + Name string `json:"name"` + Systems []string `json:"systems"` + Overwrite bool `json:"overwrite"` + }{} + err := e.BindBody(&reqData) + if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 { + return e.BadRequestError("Bad data", err) + } + + alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts") + if err != nil { + return err + } + + err = e.App.RunInTransaction(func(txApp core.App) error { + for _, systemId := range reqData.Systems { + // find existing matching alert + alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection, + "system={:system} && name={:name} && user={:user}", + dbx.Params{"system": systemId, "name": reqData.Name, "user": userID}) + + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + + // skip if alert already exists and overwrite is not set + if !reqData.Overwrite && alertRecord != nil { + continue + } + + // create new alert if it doesn't exist + if alertRecord == nil { + alertRecord = core.NewRecord(alertsCollection) + alertRecord.Set("user", userID) + alertRecord.Set("system", systemId) + alertRecord.Set("name", reqData.Name) + } + + alertRecord.Set("value", reqData.Value) + alertRecord.Set("min", reqData.Min) + + if err := txApp.SaveNoValidate(alertRecord); err != nil { + return err + } + } + return nil + }) + + if err != nil { + return err + } + + return e.JSON(http.StatusOK, map[string]any{"success": true}) +} + +// DeleteUserAlerts handles API request to delete alerts for a user across multiple systems +// (DELETE /api/beszel/user-alerts) +func DeleteUserAlerts(e *core.RequestEvent) error { + userID := e.Auth.Id + + reqData := struct { + AlertName string `json:"name"` + Systems []string `json:"systems"` + }{} + err := e.BindBody(&reqData) + if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 { + return e.BadRequestError("Bad data", err) + } + + var numDeleted uint16 + + err = e.App.RunInTransaction(func(txApp core.App) error { + for _, systemId := range reqData.Systems { + // Find existing alert to delete + alertRecord, err := txApp.FindFirstRecordByFilter("alerts", + "system={:system} && name={:name} && user={:user}", + dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID}) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + // alert doesn't exist, continue to next system + continue + } + return err + } + + if err := txApp.Delete(alertRecord); err != nil { + return err + } + numDeleted++ + } + return nil + }) + + if err != nil { + return err + } + + return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted}) +} diff --git a/beszel/internal/alerts/alerts_test.go b/beszel/internal/alerts/alerts_test.go new file mode 100644 index 0000000..71a8189 --- /dev/null +++ b/beszel/internal/alerts/alerts_test.go @@ -0,0 +1,368 @@ +//go:build testing +// +build testing + +package alerts_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + beszelTests "beszel/internal/tests" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + pbTests "github.com/pocketbase/pocketbase/tests" + "github.com/stretchr/testify/assert" +) + +// marshal to json and return an io.Reader (for use in ApiScenario.Body) +func jsonReader(v any) io.Reader { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return bytes.NewReader(data) +} + +func TestUserAlertsApi(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + hub.StartHub() + + user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password") + user1Token, _ := user1.NewAuthToken() + + user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password") + user2Token, _ := user2.NewAuthToken() + + system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "system1", + "users": []string{user1.Id}, + "host": "127.0.0.1", + }) + + system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "system2", + "users": []string{user1.Id, user2.Id}, + "host": "127.0.0.2", + }) + + userRecords, _ := hub.CountRecords("users") + assert.EqualValues(t, 2, userRecords, "all users should be created") + + systemRecords, _ := hub.CountRecords("systems") + assert.EqualValues(t, 2, systemRecords, "all systems should be created") + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenarios := []beszelTests.ApiScenario{ + { + Name: "GET not implemented - returns index", + Method: http.MethodGet, + URL: "/api/beszel/user-alerts", + ExpectedStatus: 200, + ExpectedContent: []string{" 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 }) @@ -44,7 +32,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord }) ), - [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(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 ( - <> - - - Alerts - - - - See{" "} - - notification settings - {" "} - to configure how you receive alerts. - - - - - - - - {system.name} - - - - All Systems - - - -
- {data.map((d) => ( - - ))} -
-
- - -
- {data.map((d) => ( - - ))} -
-
-
- - ) - }, [alertsSignature.join(""), overwriteExisting]) -} diff --git a/beszel/site/src/components/alerts/alerts-dialog.tsx b/beszel/site/src/components/alerts/alerts-dialog.tsx index 4839c60..4135c02 100644 --- a/beszel/site/src/components/alerts/alerts-dialog.tsx +++ b/beszel/site/src/components/alerts/alerts-dialog.tsx @@ -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 } -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() - 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(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 ( + <> + + + Alerts + + + + See{" "} + + notification settings + {" "} + to configure how you receive alerts. + + + + + + + + {system.name} + + + + All Systems + + + +
+ {alertKeys.map((name) => ( + + ))} +
+
+ + +
+ {alertKeys.map((name) => ( + + ))} +
+
+
+ + ) +}) + +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> +}) { + 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 = { + 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() - 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 => { - 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 -} - -/** - * 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 >(options: T) => { - batch ||= pb.createBatch() - batch.collection(collection).create(options) - if (++count >= batchSize) { - await send() - } - } - - const update = async >(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 (
@@ -254,7 +248,7 @@ function AlertContent({ data }: { data: AlertData }) { Average exceeds{" "} {value} - {data.alert.unit} + {alertData.unit}

@@ -262,15 +256,11 @@ function AlertContent({ data }: { data: AlertData }) { { - 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} />
@@ -292,12 +282,8 @@ function AlertContent({ data }: { data: AlertData }) { { - 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} /> diff --git a/beszel/site/src/components/routes/home.tsx b/beszel/site/src/components/routes/home.tsx index bf4867a..ee62dc7 100644 --- a/beszel/site/src/components/routes/home.tsx +++ b/beszel/site/src/components/routes/home.tsx @@ -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("systems").subscribe("*", (e) => { updateRecordList(e, $systems) }) - pb.collection("alerts").subscribe("*", (e) => { - updateRecordList(e, $alerts) - }) return () => { pb.collection("systems").unsubscribe("*") - // pb.collection('alerts').unsubscribe('*') } }, []) return useMemo( () => ( <> - {/* show active alerts */} - {activeAlerts.length > 0 && } + @@ -83,55 +60,79 @@ export const Home = memo(() => { ), - [alertsKey.join("")] + [] ) }) -const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => { - return ( - - -
- - Active Alerts - -
-
- - {activeAlerts.length > 0 && ( -
- {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>