feat(site): replace active alerts grid with banner and sheet (#2445)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-09-28 02:33:06 +02:00
committed by GitHub
parent 4b4ef0a2fe
commit 65f00ae119
8 changed files with 220 additions and 114 deletions

View File

@@ -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])
}

View 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>
)
}

View File

@@ -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 />
</>
),

View File

@@ -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 />
</>
),

View File

@@ -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 />
</>
)

View File

@@ -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 />
</>
)

View File

@@ -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"

View File

@@ -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>