show active alerts in dashboard

This commit is contained in:
Henry Dollman
2024-10-15 21:59:05 -04:00
parent 299152413a
commit 92179cbbb2
6 changed files with 154 additions and 10 deletions

View File

@@ -1,18 +1,33 @@
import { Suspense, lazy, useEffect, useState } from 'react' import { Suspense, lazy, useEffect, useMemo, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores' import { $alerts, $hubVersion, $systems, pb } from '@/lib/stores'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { GithubIcon } from 'lucide-react' import { GithubIcon } from 'lucide-react'
import { Separator } from '../ui/separator' import { Separator } from '../ui/separator'
import { updateRecordList, updateSystemList } from '@/lib/utils' import { alertInfo, updateRecordList, updateSystemList } from '@/lib/utils'
import { AlertRecord, SystemRecord } from '@/types' import { AlertRecord, SystemRecord } from '@/types'
import { Input } from '../ui/input' import { Input } from '../ui/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Link } from '../router'
const SystemsTable = lazy(() => import('../systems-table/systems-table')) const SystemsTable = lazy(() => import('../systems-table/systems-table'))
export default function () { export default function () {
const hubVersion = useStore($hubVersion) const hubVersion = useStore($hubVersion)
const [filter, setFilter] = useState<string>() const [filter, setFilter] = useState<string>()
const alerts = useStore($alerts)
const systems = useStore($systems)
const activeAlerts = useMemo(() => {
if (!systems.length) {
return []
}
const activeAlerts = alerts.filter((alert) => alert.triggered && alert.name in alertInfo)
for (const alert of activeAlerts) {
alert.sysname = systems.find((system) => system.id === alert.system)?.name
}
return activeAlerts
}, [alerts])
useEffect(() => { useEffect(() => {
document.title = 'Dashboard / Beszel' document.title = 'Dashboard / Beszel'
@@ -24,17 +39,57 @@ export default function () {
pb.collection<SystemRecord>('systems').subscribe('*', (e) => { pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
updateRecordList(e, $systems) updateRecordList(e, $systems)
}) })
// todo: add toast if new triggered alert comes in
pb.collection<AlertRecord>('alerts').subscribe('*', (e) => { pb.collection<AlertRecord>('alerts').subscribe('*', (e) => {
updateRecordList(e, $alerts) updateRecordList(e, $alerts)
}) })
return () => { return () => {
pb.collection('systems').unsubscribe('*') pb.collection('systems').unsubscribe('*')
pb.collection('alerts').unsubscribe('*') // pb.collection('alerts').unsubscribe('*')
} }
}, []) }, [])
return ( return (
<> <>
{/* show active alerts */}
{activeAlerts.length > 0 && (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>Active Alerts</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 mb-1">
{activeAlerts.map((alert) => {
const a = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<a.icon className="h-4 w-4" />
<AlertTitle className="mb-2">
{alert.sysname} {a.name}
</AlertTitle>
<AlertDescription>
Exceeds {alert.value}
{a.unit} threshold in last {alert.min} min
</AlertDescription>
<Link
href={`/system/${encodeURIComponent(alert.sysname!)}`}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-3 w-full items-end"> <div className="grid md:flex gap-3 w-full items-end">
@@ -61,6 +116,7 @@ export default function () {
</Suspense> </Suspense>
</CardContent> </CardContent>
</Card> </Card>
{hubVersion && ( {hubVersion && (
<div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pr-3 sm:pr-6 mt-3.5 text-xs opacity-80">
<a <a

View File

@@ -244,7 +244,7 @@ function AlertWithSlider({
<Suspense fallback={<div className="h-10" />}> <Suspense fallback={<div className="h-10" />}>
<div> <div>
<label htmlFor={`v${key}`} className="text-sm block h-8"> <label htmlFor={`v${key}`} className="text-sm block h-8">
Exceeds{' '} Average exceeds{' '}
<strong className="text-foreground"> <strong className="text-foreground">
{liveValue} {liveValue}
{unit} {unit}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -65,11 +65,8 @@ export function EthernetIcon(props: SVGProps<SVGSVGElement>) {
// Phosphor MIT https://github.com/phosphor-icons/core // Phosphor MIT https://github.com/phosphor-icons/core
export function ThermometerIcon(props: SVGProps<SVGSVGElement>) { export function ThermometerIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 256 256" {...props}> <svg viewBox="0 0 256 256" {...props} fill="currentColor">
<path <path d="M212 56a28 28 0 1 0 28 28 28 28 0 0 0-28-28m0 40a12 12 0 1 1 12-12 12 12 0 0 1-12 12m-60 50V40a32 32 0 0 0-64 0v106a56 56 0 1 0 64 0m-16-42h-32V40a16 16 0 0 1 32 0Z" />
fill="currentColor"
d="M212 56a28 28 0 1 0 28 28 28 28 0 0 0-28-28m0 40a12 12 0 1 1 12-12 12 12 0 0 1-12 12m-60 50V40a32 32 0 0 0-64 0v106a56 56 0 1 0 64 0m-16-42h-32V40a16 16 0 0 1 32 0Z"
/>
</svg> </svg>
) )
} }

View File

@@ -7,6 +7,8 @@ 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 { EthernetIcon, ThermometerIcon } from '@/components/ui/icons'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -52,7 +54,7 @@ export const updateSystemList = async () => {
export const updateAlerts = () => { export const updateAlerts = () => {
pb.collection('alerts') pb.collection('alerts')
.getFullList<AlertRecord>({ fields: 'id,name,system,value,min' }) .getFullList<AlertRecord>({ fields: 'id,name,system,value,min,triggered' })
.then((records) => { .then((records) => {
$alerts.set(records) $alerts.set(records)
}) })
@@ -295,3 +297,31 @@ export const getSizeAndUnit = (n: number, isGigabytes = true) => {
} }
export const chartMargin = { top: 12 } export const chartMargin = { top: 12 }
export const alertInfo = {
CPU: {
name: 'CPU usage',
unit: '%',
icon: CpuIcon,
},
Memory: {
name: 'Memory usage',
unit: '%',
icon: MemoryStickIcon,
},
Disk: {
name: 'Disk usage',
unit: '%',
icon: HardDriveIcon,
},
Bandwidth: {
name: 'Bandwidth',
unit: 'MB/s',
icon: EthernetIcon,
},
Temperature: {
name: 'Temperature',
unit: '°C',
icon: ThermometerIcon,
},
}

View File

@@ -127,6 +127,8 @@ export interface AlertRecord extends RecordModel {
id: string id: string
system: string system: string
name: string name: string
triggered: boolean
sysname?: string
// user: string // user: string
} }