From 140fd93ec98e7a15b4c8cc6822831a4454883a51 Mon Sep 17 00:00:00 2001 From: Henry Dollman Date: Sat, 19 Oct 2024 15:14:28 -0400 Subject: [PATCH] add ability to set alerts for all systems --- .../src/components/alerts/alert-button.tsx | 127 ++++++++ .../src/components/alerts/alerts-system.tsx | 246 ++++++++++++++++ .../systems-table/systems-table.tsx | 2 +- beszel/site/src/components/table-alerts.tsx | 270 ------------------ beszel/site/src/components/ui/alert.tsx | 2 +- beszel/site/src/components/ui/checkbox.tsx | 26 ++ beszel/site/src/components/ui/tabs.tsx | 53 ++++ beszel/site/src/lib/utils.ts | 10 +- 8 files changed, 463 insertions(+), 273 deletions(-) create mode 100644 beszel/site/src/components/alerts/alert-button.tsx create mode 100644 beszel/site/src/components/alerts/alerts-system.tsx delete mode 100644 beszel/site/src/components/table-alerts.tsx create mode 100644 beszel/site/src/components/ui/checkbox.tsx create mode 100644 beszel/site/src/components/ui/tabs.tsx diff --git a/beszel/site/src/components/alerts/alert-button.tsx b/beszel/site/src/components/alerts/alert-button.tsx new file mode 100644 index 0000000..59177c6 --- /dev/null +++ b/beszel/site/src/components/alerts/alert-button.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react' +import { useStore } from '@nanostores/react' +import { $alerts, $systems } 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 { Button } from '@/components/ui/button' +import { AlertRecord, SystemRecord } from '@/types' +import { Link } from '../router' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Checkbox } from '../ui/checkbox' +import { SystemAlert, SystemAlertGlobal } from './alerts-system' + +export default function AlertsButton({ system }: { system: SystemRecord }) { + const alerts = useStore($alerts) + const [opened, setOpened] = useState(false) + + const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[] + const active = systemAlerts.length > 0 + + return ( + + + + + + {opened && } + + + ) +} + +function TheContent({ + data: { system, alerts, systemAlerts }, +}: { + data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] } +}) { + const [overwriteExisting, setOverwriteExisting] = useState(false) + const systems = $systems.get() + + const data = Object.keys(alertInfo).map((key) => { + const alert = alertInfo[key as keyof typeof alertInfo] + return { + key: key 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) => ( + + ))} +
+
+
+ + ) +} diff --git a/beszel/site/src/components/alerts/alerts-system.tsx b/beszel/site/src/components/alerts/alerts-system.tsx new file mode 100644 index 0000000..745f235 --- /dev/null +++ b/beszel/site/src/components/alerts/alerts-system.tsx @@ -0,0 +1,246 @@ +import { pb } from '@/lib/stores' +import { alertInfo, cn } from '@/lib/utils' +import { Switch } from '@/components/ui/switch' +import { AlertRecord, SystemRecord } from '@/types' +import { lazy, Suspense, useRef, useState } from 'react' +import { toast } from '../ui/use-toast' +import { RecordOptions } from 'pocketbase' +import { newQueue, Queue } from '@henrygd/queue' + +interface AlertData { + checked?: boolean + val?: number + min?: number + updateAlert?: (checked: boolean, value: number, min: number) => void + key: keyof typeof alertInfo + alert: (typeof alertInfo)[keyof typeof alertInfo] + system: SystemRecord +} + +const Slider = lazy(() => import('@/components/ui/slider')) + +let queue: Queue + +const failedUpdateToast = () => + toast({ + title: 'Failed to update alert', + description: '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.key) + + 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.model!.id, + name: data.key, + value: value, + min: min, + }) + } + } catch (e) { + failedUpdateToast() + } + } + + if (alert) { + data.checked = true + data.val = alert.value + data.min = alert.min || 1 + } + + return +} + +export function SystemAlertGlobal({ + data, + overwrite, + alerts, + systems, +}: { + data: AlertData + overwrite: boolean | 'indeterminate' + alerts: AlertRecord[] + systems: SystemRecord[] +}) { + const systemsWithExistingAlerts = useRef<{ set: Set; populatedSet: boolean }>({ + set: new Set(), + populatedSet: false, + }) + + data.checked = false + data.val = data.min = 0 + + data.updateAlert = (checked: boolean, value: number, min: number) => { + if (!queue) { + queue = newQueue(5) + } + + const { set, populatedSet } = systemsWithExistingAlerts.current + + // if overwrite checked, make sure all alerts will be overwritten + if (overwrite) { + set.clear() + } + + const recordData: Partial = { + value, + min, + triggered: false, + } + for (let system of systems) { + // if overwrite is false and system is in set (alert existed), skip + if (!overwrite && set.has(system)) { + continue + } + // find matching existing alert + const existingAlert = alerts.find( + (alert) => alert.system === system.id && data.key === alert.name + ) + // if first run, add system to set (alert already existed when global panel was opened) + if (existingAlert && !populatedSet && !overwrite) { + set.add(system) + continue + } + const requestOptions: RecordOptions = { + requestKey: system.id, + } + + // checked - make sure alert is created or updated + if (checked) { + if (existingAlert) { + // console.log('updating', system.name) + queue + .add(() => pb.collection('alerts').update(existingAlert.id, recordData, requestOptions)) + .catch(failedUpdateToast) + } else { + // console.log('creating', system.name) + queue + .add(() => + pb.collection('alerts').create( + { + system: system.id, + user: pb.authStore.model!.id, + name: data.key, + ...recordData, + }, + requestOptions + ) + ) + .catch(failedUpdateToast) + } + } else if (existingAlert) { + // console.log('deleting', system.name) + queue.add(() => pb.collection('alerts').delete(existingAlert.id)).catch(failedUpdateToast) + } + } + systemsWithExistingAlerts.current.populatedSet = true + } + + return +} + +function AlertContent({ data }: { data: AlertData }) { + const { key } = data + + const hasSliders = !('single' in data.alert) + + const [checked, setChecked] = useState(data.checked || false) + const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0)) + const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0)) + + const showSliders = checked && hasSliders + + const newMin = useRef(min) + const newValue = useRef(value) + + const Icon = alertInfo[key].icon + + const updateAlert = (c?: boolean) => + data.updateAlert?.(c ?? checked, newValue.current, newMin.current) + + return ( +
+ + {showSliders && ( +
+ }> +
+

+ Average exceeds{' '} + + {value} + {data.alert.unit} + +

+
+ (newValue.current = val[0]) && updateAlert()} + onValueChange={(val) => setValue(val[0])} + min={1} + max={99} + /> +
+
+
+

+ For {min} minute + {min > 1 && 's'} +

+
+ (newMin.current = val[0]) && updateAlert()} + onValueChange={(val) => setMin(val[0])} + min={1} + max={60} + /> +
+
+
+
+ )} +
+ ) +} diff --git a/beszel/site/src/components/systems-table/systems-table.tsx b/beszel/site/src/components/systems-table/systems-table.tsx index b595605..43ca866 100644 --- a/beszel/site/src/components/systems-table/systems-table.tsx +++ b/beszel/site/src/components/systems-table/systems-table.tsx @@ -60,7 +60,7 @@ import { useEffect, useMemo, useState } from 'react' import { $hubVersion, $systems, pb } from '@/lib/stores' import { useStore } from '@nanostores/react' import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils' -import AlertsButton from '../table-alerts' +import AlertsButton from '../alerts/alert-button' import { navigate } from '../router' import { EthernetIcon } from '../ui/icons' diff --git a/beszel/site/src/components/table-alerts.tsx b/beszel/site/src/components/table-alerts.tsx deleted file mode 100644 index 2a34278..0000000 --- a/beszel/site/src/components/table-alerts.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { $alerts, pb } from '@/lib/stores' -import { useStore } from '@nanostores/react' -import { - Dialog, - DialogTrigger, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { BellIcon, ServerIcon } from 'lucide-react' -import { alertInfo, cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { Switch } from '@/components/ui/switch' -import { AlertRecord, SystemRecord } from '@/types' -import { lazy, Suspense, useMemo, useState } from 'react' -import { toast } from './ui/use-toast' -import { Link } from './router' - -const Slider = lazy(() => import('./ui/slider')) - -const failedUpdateToast = () => - toast({ - title: 'Failed to update alert', - description: 'Please check logs for more details.', - variant: 'destructive', - }) - -export default function AlertsButton({ system }: { system: SystemRecord }) { - const alerts = useStore($alerts) - const [opened, setOpened] = useState(false) - - const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[] - - const active = systemAlerts.length > 0 - - return ( - - - - - setOpened(false)} - > - {opened && ( - <> - - {system.name} alerts - - See{' '} - - notification settings - {' '} - to configure how you receive alerts. - - -
- - {Object.keys(alertInfo).map((key) => { - const alert = alertInfo[key as keyof typeof alertInfo] - return ( - - ) - })} -
- - )} -
-
- ) -} - -function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) { - const [pendingChange, setPendingChange] = useState(false) - - const alert = alerts.find((alert) => alert.name === 'Status') - - return ( - - ) -} - -function AlertWithSlider({ - system, - alerts, - name, - title, - description, - unit = '%', - max = 99, - Icon, -}: { - system: SystemRecord - alerts: AlertRecord[] - name: string - title: string - description: string - unit?: string - max?: number - Icon: React.FC> -}) { - const [pendingChange, setPendingChange] = useState(false) - const [value, setValue] = useState(80) - const [min, setMin] = useState(10) - - const key = name.replaceAll(' ', '-') - - const alert = useMemo(() => { - const alert = alerts.find((alert) => alert.name === name) - if (alert) { - setValue(alert.value) - setMin(alert.min || 1) - } - return alert - }, [alerts]) - - const updateAlert = (obj: Partial) => { - obj.triggered = false - alert && pb.collection('alerts').update(alert.id, obj) - } - - return ( -
- - {alert && ( -
- }> -
-

- Average exceeds{' '} - - {value} - {unit} - -

-
- updateAlert({ value: val[0] })} - onValueChange={(val) => setValue(val[0])} - min={1} - max={max} - /> -
-
-
-

- For {min} minute - {min > 1 && 's'} -

-
- updateAlert({ min: val[0] })} - onValueChange={(val) => setMin(val[0])} - min={1} - max={60} - /> -
-
-
-
- )} -
- ) -} diff --git a/beszel/site/src/components/ui/alert.tsx b/beszel/site/src/components/ui/alert.tsx index 678aa08..df76eb7 100644 --- a/beszel/site/src/components/ui/alert.tsx +++ b/beszel/site/src/components/ui/alert.tsx @@ -41,7 +41,7 @@ const AlertTitle = React.forwardRef (
) diff --git a/beszel/site/src/components/ui/checkbox.tsx b/beszel/site/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..7c72dc3 --- /dev/null +++ b/beszel/site/src/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/beszel/site/src/components/ui/tabs.tsx b/beszel/site/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f57fffd --- /dev/null +++ b/beszel/site/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts index 8528585..a221c64 100644 --- a/beszel/site/src/lib/utils.ts +++ b/beszel/site/src/lib/utils.ts @@ -7,8 +7,9 @@ import { RecordModel, RecordSubscription } from 'pocketbase' import { WritableAtom } from 'nanostores' import { timeDay, timeHour } from 'd3-time' import { useEffect, useState } from 'react' -import { CpuIcon, HardDriveIcon, MemoryStickIcon } from 'lucide-react' +import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from 'lucide-react' import { EthernetIcon, ThermometerIcon } from '@/components/ui/icons' +import { newQueue, Queue } from '@henrygd/queue' export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -299,6 +300,13 @@ export const getSizeAndUnit = (n: number, isGigabytes = true) => { export const chartMargin = { top: 12 } export const alertInfo = { + Status: { + name: 'Status', + unit: '', + icon: ServerIcon, + desc: 'Triggers when status switches between up and down.', + single: true, + }, CPU: { name: 'CPU usage', unit: '%',