Files
beszel-ipv6/beszel/site/src/components/table-alerts.tsx
2024-10-17 15:46:09 -04:00

271 lines
7.1 KiB
Go

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 (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size={'icon'}
aria-label="Alerts"
data-nolink
onClick={() => setOpened(true)}
>
<BellIcon
className={cn('h-[1.2em] w-[1.2em] pointer-events-none', {
'fill-foreground': active,
})}
/>
</Button>
</DialogTrigger>
<DialogContent
className="max-h-full overflow-auto max-w-[35rem]"
// onCloseAutoFocus={() => setOpened(false)}
>
{opened && (
<>
<DialogHeader>
<DialogTitle className="text-xl">{system.name} alerts</DialogTitle>
<DialogDescription className="mb-1">
See{' '}
<Link href="/settings/notifications" className="link">
notification settings
</Link>{' '}
to configure how you receive alerts.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<AlertStatus system={system} alerts={systemAlerts} />
{Object.keys(alertInfo).map((key) => {
const alert = alertInfo[key as keyof typeof alertInfo]
return (
<AlertWithSlider
key={key}
system={system}
alerts={systemAlerts}
name={key}
title={alert.name}
description={alert.desc}
unit={alert.unit}
Icon={alert.icon}
/>
)
})}
</div>
</>
)}
</DialogContent>
</Dialog>
)
}
function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) {
const [pendingChange, setPendingChange] = useState(false)
const alert = alerts.find((alert) => alert.name === 'Status')
return (
<label
htmlFor="alert-status"
className="flex flex-row items-center justify-between gap-4 rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 p-4 cursor-pointer"
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<ServerIcon className="h-4 w-4 opacity-85" /> System Status
</p>
<span className="block text-sm text-muted-foreground">
Triggers when status switches between up and down.
</span>
</div>
<Switch
id="alert-status"
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
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)
}
}}
/>
</label>
)
}
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<React.SVGProps<SVGSVGElement>>
}) {
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<AlertRecord>) => {
obj.triggered = false
alert && pb.collection('alerts').update(alert.id, obj)
}
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`s${key}`}
className={cn('flex flex-row items-center justify-between gap-4 cursor-pointer p-4', {
'pb-0': !!alert,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center capitalize">
<Icon className="h-4 w-4 opacity-85" /> {title}
</p>
{!alert && <span className="block text-sm text-muted-foreground">{description}</span>}
</div>
<Switch
id={`s${key}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
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)
}
}}
/>
</label>
{alert && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
<div>
<p id={`v${key}`} className="text-sm block h-8">
Average exceeds{' '}
<strong className="text-foreground">
{value}
{unit}
</strong>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[value]}
onValueCommit={(val) => updateAlert({ value: val[0] })}
onValueChange={(val) => setValue(val[0])}
min={1}
max={max}
/>
</div>
</div>
<div>
<p id={`t${key}`} className="text-sm block h-8">
For <strong className="text-foreground">{min}</strong> minute
{min > 1 && 's'}
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${key}`}
defaultValue={[min]}
onValueCommit={(val) => updateAlert({ min: val[0] })}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}