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 { 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'
|
||||
|
||||
|
@@ -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) => (
|
||||
<h5
|
||||
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}
|
||||
/>
|
||||
)
|
||||
|
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 { 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: '%',
|
||||
|
Reference in New Issue
Block a user