mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 02:09:28 +08:00
add ability to set alerts for all systems
This commit is contained in:
127
beszel/site/src/components/alerts/alert-button.tsx
Normal file
127
beszel/site/src/components/alerts/alert-button.tsx
Normal file
@@ -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 (
|
||||||
|
<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-primary': active,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-full overflow-auto max-w-[35rem]">
|
||||||
|
{opened && <TheContent data={{ system, alerts, systemAlerts }} />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TheContent({
|
||||||
|
data: { system, alerts, systemAlerts },
|
||||||
|
}: {
|
||||||
|
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
|
||||||
|
}) {
|
||||||
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | 'indeterminate'>(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 (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">Alerts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
See{' '}
|
||||||
|
<Link href="/settings/notifications" className="link">
|
||||||
|
notification settings
|
||||||
|
</Link>{' '}
|
||||||
|
to configure how you receive alerts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="system">
|
||||||
|
<TabsList className="mb-1 -mt-0.5">
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<ServerIcon className="mr-2 h-3.5 w-3.5" />
|
||||||
|
{system.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<GlobeIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
All systems
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="system">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{data.map((d) => (
|
||||||
|
<SystemAlert key={d.key} system={system} data={d} systemAlerts={systemAlerts} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="global">
|
||||||
|
<label
|
||||||
|
htmlFor="ovw"
|
||||||
|
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="ovw"
|
||||||
|
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
||||||
|
checked={overwriteExisting}
|
||||||
|
onCheckedChange={setOverwriteExisting}
|
||||||
|
/>
|
||||||
|
Overwrite existing alerts
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{data.map((d) => (
|
||||||
|
<SystemAlertGlobal
|
||||||
|
key={d.key}
|
||||||
|
data={d}
|
||||||
|
overwrite={overwriteExisting}
|
||||||
|
alerts={alerts}
|
||||||
|
systems={systems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
246
beszel/site/src/components/alerts/alerts-system.tsx
Normal file
246
beszel/site/src/components/alerts/alerts-system.tsx
Normal file
@@ -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 <AlertContent data={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemAlertGlobal({
|
||||||
|
data,
|
||||||
|
overwrite,
|
||||||
|
alerts,
|
||||||
|
systems,
|
||||||
|
}: {
|
||||||
|
data: AlertData
|
||||||
|
overwrite: boolean | 'indeterminate'
|
||||||
|
alerts: AlertRecord[]
|
||||||
|
systems: SystemRecord[]
|
||||||
|
}) {
|
||||||
|
const systemsWithExistingAlerts = useRef<{ set: Set<SystemRecord>; 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<AlertRecord> = {
|
||||||
|
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 <AlertContent data={data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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': showSliders,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<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" /> {data.alert.name}
|
||||||
|
</p>
|
||||||
|
{!showSliders && (
|
||||||
|
<span className="block text-sm text-muted-foreground">{data.alert.desc}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`s${key}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setChecked(checked)
|
||||||
|
updateAlert(checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{showSliders && (
|
||||||
|
<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}
|
||||||
|
{data.alert.unit}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${key}`}
|
||||||
|
defaultValue={[value]}
|
||||||
|
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()}
|
||||||
|
onValueChange={(val) => setValue(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={99}
|
||||||
|
/>
|
||||||
|
</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) => (newMin.current = val[0]) && updateAlert()}
|
||||||
|
onValueChange={(val) => setMin(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@@ -60,7 +60,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { $hubVersion, $systems, pb } from '@/lib/stores'
|
import { $hubVersion, $systems, pb } from '@/lib/stores'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils'
|
import { cn, copyToClipboard, decimalString, isReadOnlyUser } from '@/lib/utils'
|
||||||
import AlertsButton from '../table-alerts'
|
import AlertsButton from '../alerts/alert-button'
|
||||||
import { navigate } from '../router'
|
import { navigate } from '../router'
|
||||||
import { EthernetIcon } from '../ui/icons'
|
import { EthernetIcon } from '../ui/icons'
|
||||||
|
|
||||||
|
@@ -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 (
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -41,7 +41,7 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h5
|
<h5
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
className={cn('mb-1 -mt-0.5 font-medium leading-tight tracking-tight', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
26
beszel/site/src/components/ui/checkbox.tsx
Normal file
26
beszel/site/src/components/ui/checkbox.tsx
Normal file
@@ -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<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
53
beszel/site/src/components/ui/tabs.tsx
Normal file
53
beszel/site/src/components/ui/tabs.tsx
Normal file
@@ -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<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
@@ -7,8 +7,9 @@ import { RecordModel, RecordSubscription } from 'pocketbase'
|
|||||||
import { WritableAtom } from 'nanostores'
|
import { WritableAtom } from 'nanostores'
|
||||||
import { timeDay, timeHour } from 'd3-time'
|
import { timeDay, timeHour } from 'd3-time'
|
||||||
import { useEffect, useState } from 'react'
|
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 { EthernetIcon, ThermometerIcon } from '@/components/ui/icons'
|
||||||
|
import { newQueue, Queue } from '@henrygd/queue'
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -299,6 +300,13 @@ export const getSizeAndUnit = (n: number, isGigabytes = true) => {
|
|||||||
export const chartMargin = { top: 12 }
|
export const chartMargin = { top: 12 }
|
||||||
|
|
||||||
export const alertInfo = {
|
export const alertInfo = {
|
||||||
|
Status: {
|
||||||
|
name: 'Status',
|
||||||
|
unit: '',
|
||||||
|
icon: ServerIcon,
|
||||||
|
desc: 'Triggers when status switches between up and down.',
|
||||||
|
single: true,
|
||||||
|
},
|
||||||
CPU: {
|
CPU: {
|
||||||
name: 'CPU usage',
|
name: 'CPU usage',
|
||||||
unit: '%',
|
unit: '%',
|
||||||
|
Reference in New Issue
Block a user