mirror of
https://github.com/fankes/beszel.git
synced 2025-10-20 02:09:28 +08:00
show active alerts in dashboard
This commit is contained in:
@@ -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
|
||||||
|
@@ -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}
|
||||||
|
59
beszel/site/src/components/ui/alert.tsx
Normal file
59
beszel/site/src/components/ui/alert.tsx
Normal 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 }
|
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
2
beszel/site/src/types.d.ts
vendored
2
beszel/site/src/types.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user