import { t } from "@lingui/core/macro" import { Plural, Trans } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { ChevronDownIcon, GlobeIcon, ServerIcon } from "lucide-react" import { lazy, memo, Suspense, useMemo, useState } from "react" import { $router, Link } from "@/components/router" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "@/components/ui/use-toast" import { alertInfo } from "@/lib/alerts" import { pb } from "@/lib/api" import { $alerts, $systems } from "@/lib/stores" import { cn, debounce } from "@/lib/utils" import type { AlertInfo, AlertRecord, SystemRecord } from "@/types" const Slider = lazy(() => import("@/components/ui/slider")) const endpoint = "/api/beszel/user-alerts" const alertDebounce = 400 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", }) } /** 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 ) /** 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 systems = useStore($systems) const [overwriteExisting, setOverwriteExisting] = useState(false) const [currentTab, setCurrentTab] = useState("system") // copyKey is used to force remount AlertContent components with // new alert data after copying alerts from another system const [copyKey, setCopyKey] = useState(0) const systemAlerts = alerts[system.id] ?? new Map() // Systems that have at least one alert configured (excluding the current system) const systemsWithAlerts = useMemo( () => systems.filter((s) => s.id !== system.id && alerts[s.id]?.size), [systems, alerts, system.id] ) async function copyAlertsFromSystem(sourceSystemId: string) { const sourceAlerts = $alerts.get()[sourceSystemId] if (!sourceAlerts?.size) return try { const currentTargetAlerts = $alerts.get()[system.id] ?? new Map() // Alert names present on target but absent from source should be deleted const namesToDelete = Array.from(currentTargetAlerts.keys()).filter((name) => !sourceAlerts.has(name)) await Promise.all([ ...Array.from(sourceAlerts.values()).map(({ name, value, min }) => pb.send<{ success: boolean }>(endpoint, { method: "POST", body: { name, value, min, systems: [system.id], overwrite: true }, requestKey: name, }) ), ...namesToDelete.map((name) => pb.send<{ success: boolean }>(endpoint, { method: "DELETE", body: { name, systems: [system.id] }, requestKey: name, }) ), ]) // Optimistically update the store so components re-mount with correct data // before the realtime subscription event arrives. const newSystemAlerts = new Map() for (const alert of sourceAlerts.values()) { newSystemAlerts.set(alert.name, { ...alert, system: system.id, triggered: false }) } $alerts.setKey(system.id, newSystemAlerts) setCopyKey((k) => k + 1) } catch (error) { failedUpdateToast(error) } } // 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 {systemsWithAlerts.length > 0 && currentTab === "system" && ( {systemsWithAlerts.map((s) => ( copyAlertsFromSystem(s.id)}> {s.name} ))} )}
{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 systemIds } function sendUpsert(min: number, value: number) { const systems = getSystemIds() systems.length && upsertAlerts({ name: alertKey, value, min, systems, }) } return (
{checked && (
}> {!singleDescription && (

{alertData.invert ? ( Average drops below{" "} {value} {alertData.unit} ) : ( Average exceeds{" "} {value} {alertData.unit} )}

sendUpsert(min, val[0])} onValueChange={(val) => setValue(val[0])} step={alertData.step ?? 1} min={alertData.min ?? 1} max={alertData.max ?? 99} /> { let val = parseFloat(e.target.value) if (!Number.isNaN(val)) { if (alertData.max != null) val = Math.min(val, alertData.max) if (alertData.min != null) val = Math.max(val, alertData.min) setValue(val) sendUpsert(min, val) } }} step={alertData.step ?? 1} min={alertData.min ?? 1} max={alertData.max ?? 99} className="w-16 h-8 text-center px-1" />
)}

{singleDescription && ( <> {singleDescription} {` `} )} For {min}{" "}

sendUpsert(val[0], value)} onValueChange={(val) => setMin(val[0])} min={1} max={60} /> { let val = parseInt(e.target.value, 10) if (!Number.isNaN(val)) { val = Math.max(1, Math.min(val, 60)) setMin(val) sendUpsert(val, value) } }} min={1} max={60} className="w-16 h-8 text-center px-1" />
)}
) }