mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-28 04:17:46 +02:00
feat(site): replace active alerts grid with banner and sheet (#2445)
Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
@@ -4,17 +4,55 @@ import type { AlertRecord } from "@/types"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { useMemo } from "react"
|
||||
import { $router, Link } from "./router"
|
||||
import { Alert, AlertTitle, AlertDescription } from "./ui/alert"
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"
|
||||
import { useMemo, useState } from "react"
|
||||
import { AlertBannerSheet, AlertBannerSheetItem } from "./alert-banner-sheet"
|
||||
import { $router } from "./router"
|
||||
|
||||
export const ActiveAlerts = () => {
|
||||
function AlertTriggeredDesc({ alert }: { alert: AlertRecord }) {
|
||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||
if (info.triggeredDesc) {
|
||||
return info.triggeredDesc()
|
||||
}
|
||||
if (alert.name === "NetworkMonitorLoss") {
|
||||
return <Trans>One or more monitors exceed {alert.value}% loss</Trans>
|
||||
}
|
||||
if (alert.name === "Status") {
|
||||
return <Trans>Connection is down</Trans>
|
||||
}
|
||||
if (info.invert) {
|
||||
return (
|
||||
<Trans>
|
||||
Below {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Trans>
|
||||
Exceeds {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertLabel({ alert, systemName }: { alert: AlertRecord; systemName?: string }) {
|
||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||
return (
|
||||
<>
|
||||
{systemName} <span className="opacity-60 font-normal">·</span> {info.name()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Banner showing the number of triggered alerts, with a sheet listing them. */
|
||||
export const ActiveAlerts = ({ className }: { className?: string }) => {
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($allSystemsById)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||
const { activeAlerts, systemCount, alertsKey } = useMemo(() => {
|
||||
const activeAlerts: AlertRecord[] = []
|
||||
const systemIds = new Set<string>()
|
||||
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||
const alertsKey: string[] = []
|
||||
|
||||
@@ -22,73 +60,62 @@ export const ActiveAlerts = () => {
|
||||
for (const alert of alerts[systemId].values()) {
|
||||
if (alert.triggered && alert.name in alertInfo) {
|
||||
activeAlerts.push(alert)
|
||||
systemIds.add(alert.system)
|
||||
alertsKey.push(`${alert.id}${alert.value}${alert.min}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { activeAlerts, alertsKey }
|
||||
return { activeAlerts, systemCount: systemIds.size, alertsKey: alertsKey.join("") }
|
||||
}, [alerts])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
||||
return useMemo(() => {
|
||||
if (activeAlerts.length === 0) {
|
||||
const alertCount = activeAlerts.length
|
||||
if (alertCount === 0) {
|
||||
return null
|
||||
}
|
||||
// name the alert directly in the banner when there is only one
|
||||
const [firstAlert] = activeAlerts
|
||||
return (
|
||||
<Card>
|
||||
<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>
|
||||
<Trans>Active Alerts</Trans>
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="max-sm:p-2">
|
||||
{activeAlerts.length > 0 && (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||
{activeAlerts.map((alert) => {
|
||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||
return (
|
||||
<Alert
|
||||
key={alert.id}
|
||||
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
|
||||
>
|
||||
<info.icon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{systems[alert.system]?.name} {info.name()}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{info.triggeredDesc ? (
|
||||
info.triggeredDesc()
|
||||
) : alert.name === "NetworkMonitorLoss" ? (
|
||||
<Trans>One or more monitors exceed {alert.value}% loss</Trans>
|
||||
) : alert.name === "Status" ? (
|
||||
<Trans>Connection is down</Trans>
|
||||
) : info.invert ? (
|
||||
<Trans>
|
||||
Below {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Exceeds {alert.value}
|
||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||
</Trans>
|
||||
)}
|
||||
</AlertDescription>
|
||||
<Link
|
||||
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
aria-label="View system"
|
||||
></Link>
|
||||
</Alert>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AlertBannerSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={className}
|
||||
title={
|
||||
alertCount === 1 ? (
|
||||
<AlertLabel alert={firstAlert} systemName={systems[firstAlert.system]?.name} />
|
||||
) : (
|
||||
<Plural value={alertCount} one="# active alert" other="# active alerts" />
|
||||
)
|
||||
}
|
||||
description={
|
||||
alertCount === 1 ? (
|
||||
<AlertTriggeredDesc alert={firstAlert} />
|
||||
) : (
|
||||
<Plural value={systemCount} one="Across # system" other="Across # systems" />
|
||||
)
|
||||
}
|
||||
buttonLabel={<Trans>View alerts</Trans>}
|
||||
sheetTitle={<Trans>Active Alerts</Trans>}
|
||||
sheetDescription={
|
||||
<Plural value={alertCount} one="# alert is currently triggered" other="# alerts are currently triggered" />
|
||||
}
|
||||
>
|
||||
{activeAlerts.map((alert) => {
|
||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||
const system = systems[alert.system]
|
||||
return (
|
||||
<AlertBannerSheetItem
|
||||
key={alert.id}
|
||||
href={getPagePath($router, "system", { id: system?.id })}
|
||||
onClick={() => setOpen(false)}
|
||||
icon={info.icon}
|
||||
title={<AlertLabel alert={alert} systemName={system?.name} />}
|
||||
description={<AlertTriggeredDesc alert={alert} />}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</AlertBannerSheet>
|
||||
)
|
||||
}, [alertsKey.join("")])
|
||||
}, [alertsKey, systemCount, systems, open, className])
|
||||
}
|
||||
|
||||
101
internal/site/src/components/alert-banner-sheet.tsx
Normal file
101
internal/site/src/components/alert-banner-sheet.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Link } from "./router"
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"
|
||||
import { Button } from "./ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet"
|
||||
|
||||
/**
|
||||
* Destructive alert banner with a button that opens a sheet containing details.
|
||||
* Content agnostic so it can be reused for system alerts, network monitors, etc.
|
||||
*/
|
||||
export function AlertBannerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
buttonLabel,
|
||||
sheetTitle,
|
||||
sheetDescription,
|
||||
icon: Icon = TriangleAlertIcon,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
buttonLabel?: ReactNode
|
||||
sheetTitle: ReactNode
|
||||
sheetDescription?: ReactNode
|
||||
icon?: LucideIcon
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<Alert variant="destructive" className={cn("flex items-center gap-3 py-3 max-sm:flex-wrap", className)}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<AlertTitle className="m-0">{title}</AlertTitle>
|
||||
{description && (
|
||||
<AlertDescription className="text-destructive/80 dark:text-red-500/80">{description}</AlertDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 bg-transparent border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive dark:text-red-500 dark:hover:bg-destructive/15 dark:hover:text-red-500 max-sm:w-full"
|
||||
>
|
||||
{buttonLabel ?? <Trans>View details</Trans>}
|
||||
<ChevronRightIcon className="size-4 ms-1 -me-1" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</Alert>
|
||||
<SheetContent className="w-140 !max-w-full gap-0">
|
||||
<SheetHeader className="p-4 sm:p-6 pb-3 sm:pb-4 border-b">
|
||||
<SheetTitle>{sheetTitle}</SheetTitle>
|
||||
{sheetDescription && <SheetDescription>{sheetDescription}</SheetDescription>}
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-auto p-4 sm:p-6 pt-3 sm:pt-4 flex flex-col gap-2.5">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
/** Clickable row for use inside AlertBannerSheet. */
|
||||
export function AlertBannerSheetItem({
|
||||
href,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
href: string
|
||||
onClick?: () => void
|
||||
icon: LucideIcon | React.FC<{ className?: string }>
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<div className="rounded-md bg-destructive/10 p-2 text-destructive shrink-0">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-tight truncate">{title}</div>
|
||||
{description && <div className="text-sm text-muted-foreground mt-1">{description}</div>}
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 self-center shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import ContainersTable from "@/components/containers-table/containers-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default memo(() => {
|
||||
@@ -14,10 +13,7 @@ export default memo(() => {
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<ContainersTable />
|
||||
</div>
|
||||
<ContainersTable />
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, Suspense, useEffect, useMemo } from "react"
|
||||
import SystemsTable from "@/components/systems-table/systems-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default memo(() => {
|
||||
@@ -14,12 +13,9 @@ export default memo(() => {
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ActiveAlerts />
|
||||
<Suspense>
|
||||
<SystemsTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense>
|
||||
<SystemsTable />
|
||||
</Suspense>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect } from "react"
|
||||
import NetworkMonitorsTableNew from "@/components/network-monitors-table/network-monitors-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
import { useNetworkMonitors } from "@/lib/use-network-monitors"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
@@ -23,10 +22,7 @@ export default memo(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<NetworkMonitorsTableNew monitors={visibleMonitors} isLoading={isLoading} />
|
||||
</div>
|
||||
<NetworkMonitorsTableNew monitors={visibleMonitors} isLoading={isLoading} />
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect } from "react"
|
||||
import SmartTable from "@/components/routes/system/smart-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default function Smart() {
|
||||
@@ -10,10 +9,7 @@ export default function Smart() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<SmartTable />
|
||||
</div>
|
||||
<SmartTable />
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
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~*]:ps-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 alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground [&>svg]:text-foreground",
|
||||
destructive:
|
||||
"bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-500 dark:border-destructive/70 [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
// React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
// >(({ className, variant, ...props }, ref) => (
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:ps-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const Smart = lazy(() => import("@/components/routes/smart.tsx"))
|
||||
const Monitors = lazy(() => import("@/components/routes/monitors.tsx"))
|
||||
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||
const ActiveAlerts = lazy(() => import("@/components/active-alerts.tsx").then((m) => ({ default: m.ActiveAlerts })))
|
||||
|
||||
const App = memo(() => {
|
||||
const page = useStore($router)
|
||||
@@ -109,6 +110,9 @@ const Layout = () => {
|
||||
<Navbar />
|
||||
</div>
|
||||
<div className="container relative">
|
||||
<Suspense>
|
||||
<ActiveAlerts className="mb-4" />
|
||||
</Suspense>
|
||||
<App />
|
||||
{copyContent && (
|
||||
<Suspense>
|
||||
|
||||
Reference in New Issue
Block a user