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 00000000..59177c6d --- /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 ( + + + setOpened(true)} + > + + + + + {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) => ( + + ))} + + + + + + Overwrite existing alerts + + + {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 00000000..745f235b --- /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 ( + + + + + {data.alert.name} + + {!showSliders && ( + {data.alert.desc} + )} + + { + setChecked(checked) + updateAlert(checked) + }} + /> + + {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 b5956050..43ca866b 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 2a34278b..00000000 --- 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(true)} - > - - - - 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 ( - - - - System Status - - - Triggers when status switches between up and down. - - - { - if (pendingChange) { - return - } - setPendingChange(true) - try { - if (!active && alert) { - await pb.collection('alerts').delete(alert.id) - } else if (active) { - pb.collection('alerts').create({ - system: system.id, - user: pb.authStore.model!.id, - name: 'Status', - }) - } - } catch (e) { - failedUpdateToast() - } finally { - setPendingChange(false) - } - }} - /> - - ) -} - -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 ( - - - - - {title} - - {!alert && {description}} - - { - if (pendingChange) { - return - } - setPendingChange(true) - try { - if (!active && alert) { - await pb.collection('alerts').delete(alert.id) - } else if (active) { - pb.collection('alerts').create({ - system: system.id, - user: pb.authStore.model!.id, - name, - value: value, - min: min, - }) - } - } catch (e) { - failedUpdateToast() - } finally { - setPendingChange(false) - } - }} - /> - - {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 678aa086..df76eb7d 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 00000000..7c72dc3a --- /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 00000000..f57fffdb --- /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 85285856..a221c64c 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: '%',
+ {data.alert.name} +
+ Average exceeds{' '} + + {value} + {data.alert.unit} + +
+ For {min} minute + {min > 1 && 's'} +
- System Status -
- {title} -
- Average exceeds{' '} - - {value} - {unit} - -
- For {min} minute - {min > 1 && 's'} -