diff --git a/internal/site/src/components/active-alerts.tsx b/internal/site/src/components/active-alerts.tsx index 1e71762ff..41af5f35d 100644 --- a/internal/site/src/components/active-alerts.tsx +++ b/internal/site/src/components/active-alerts.tsx @@ -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 One or more monitors exceed {alert.value}% loss + } + if (alert.name === "Status") { + return Connection is down + } + if (info.invert) { + return ( + + Below {alert.value} + {info.unit} in last + + ) + } + return ( + + Exceeds {alert.value} + {info.unit} in last + + ) +} + +function AlertLabel({ alert, systemName }: { alert: AlertRecord; systemName?: string }) { + const info = alertInfo[alert.name as keyof typeof alertInfo] + return ( + <> + {systemName} ยท {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() // 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 ( - - -
- - Active Alerts - -
-
- - {activeAlerts.length > 0 && ( -
- {activeAlerts.map((alert) => { - const info = alertInfo[alert.name as keyof typeof alertInfo] - return ( - - - - {systems[alert.system]?.name} {info.name()} - - - {info.triggeredDesc ? ( - info.triggeredDesc() - ) : alert.name === "NetworkMonitorLoss" ? ( - One or more monitors exceed {alert.value}% loss - ) : alert.name === "Status" ? ( - Connection is down - ) : info.invert ? ( - - Below {alert.value} - {info.unit} in last - - ) : ( - - Exceeds {alert.value} - {info.unit} in last - - )} - - - - ) - })} -
- )} -
-
+ + ) : ( + + ) + } + description={ + alertCount === 1 ? ( + + ) : ( + + ) + } + buttonLabel={View alerts} + sheetTitle={Active Alerts} + sheetDescription={ + + } + > + {activeAlerts.map((alert) => { + const info = alertInfo[alert.name as keyof typeof alertInfo] + const system = systems[alert.system] + return ( + setOpen(false)} + icon={info.icon} + title={} + description={} + /> + ) + })} + ) - }, [alertsKey.join("")]) + }, [alertsKey, systemCount, systems, open, className]) } diff --git a/internal/site/src/components/alert-banner-sheet.tsx b/internal/site/src/components/alert-banner-sheet.tsx new file mode 100644 index 000000000..ab066f954 --- /dev/null +++ b/internal/site/src/components/alert-banner-sheet.tsx @@ -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 ( + + +
+ +
+ {title} + {description && ( + {description} + )} +
+
+ + + +
+ + + {sheetTitle} + {sheetDescription && {sheetDescription}} + +
{children}
+
+
+ ) +} + +/** 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 ( + +
+ +
+
+
{title}
+ {description &&
{description}
} +
+ + + ) +} diff --git a/internal/site/src/components/routes/containers.tsx b/internal/site/src/components/routes/containers.tsx index 1726840b5..da0144d77 100644 --- a/internal/site/src/components/routes/containers.tsx +++ b/internal/site/src/components/routes/containers.tsx @@ -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( () => ( <> -
- - -
+ ), diff --git a/internal/site/src/components/routes/home.tsx b/internal/site/src/components/routes/home.tsx index a635c556f..d21a1e0d0 100644 --- a/internal/site/src/components/routes/home.tsx +++ b/internal/site/src/components/routes/home.tsx @@ -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( () => ( <> -
- - - - -
+ + + ), diff --git a/internal/site/src/components/routes/monitors.tsx b/internal/site/src/components/routes/monitors.tsx index 51bee7790..e7fba73d0 100644 --- a/internal/site/src/components/routes/monitors.tsx +++ b/internal/site/src/components/routes/monitors.tsx @@ -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 ( <> -
- - -
+ ) diff --git a/internal/site/src/components/routes/smart.tsx b/internal/site/src/components/routes/smart.tsx index 99f517c24..e794fe085 100644 --- a/internal/site/src/components/routes/smart.tsx +++ b/internal/site/src/components/routes/smart.tsx @@ -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 ( <> -
- - -
+ ) diff --git a/internal/site/src/components/ui/alert.tsx b/internal/site/src/components/ui/alert.tsx index 165391b00..52e922370 100644 --- a/internal/site/src/components/ui/alert.tsx +++ b/internal/site/src/components/ui/alert.tsx @@ -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 & VariantProps - // >(({ className, variant, ...props }, ref) => ( - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
)) Alert.displayName = "Alert" diff --git a/internal/site/src/main.tsx b/internal/site/src/main.tsx index 92f8da1c2..f3db08f9a 100644 --- a/internal/site/src/main.tsx +++ b/internal/site/src/main.tsx @@ -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 = () => {
+ + + {copyContent && (