Implement container health alerts (#1679)

* Implement container health alerts, including a database migration for alert names and UI updates.

* Add bulk container alerts and improve alerting logic

* Implement container-specific alerts with new database collections, API endpoints, and UI components.
This commit is contained in:
Jaypalsinh Barad
2026-09-02 22:49:40 +05:30
committed by GitHub
parent 79ca31d770
commit 734c966307
16 changed files with 1408 additions and 8 deletions

View File

@@ -0,0 +1,44 @@
import { t } from "@lingui/core/macro"
import { useStore } from "@nanostores/react"
import { BellIcon } from "lucide-react"
import { memo, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { $containerAlerts } from "@/lib/stores"
import { cn } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
import { ContainerAlertDialogContent } from "./container-alerts-sheet"
export default memo(function ContainerAlertButton({
systemId,
container,
}: {
systemId: string
container: ContainerRecord
}) {
const [opened, setOpened] = useState(false)
const alerts = useStore($containerAlerts)
const containerAlerts = alerts[systemId]?.get(container.id)
const hasContainerAlert = containerAlerts && containerAlerts.size > 0
return useMemo(
() => (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
"fill-primary": hasContainerAlert,
})}
/>
</Button>
</SheetTrigger>
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
{opened && <ContainerAlertDialogContent systemId={systemId} container={container} />}
</SheetContent>
</Sheet>
),
[opened, hasContainerAlert]
)
})

View File

@@ -0,0 +1,327 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { BoxIcon, GlobeIcon } from "lucide-react"
import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/use-toast"
import { containerAlertInfo } from "@/lib/container-alerts"
import { pb } from "@/lib/api"
import { $containerAlerts } from "@/lib/stores"
import { cn, debounce } from "@/lib/utils"
import type { ContainerAlertInfo, ContainerAlertRecord, ContainerRecord } from "@/types"
const Slider = lazy(() => import("@/components/ui/slider"))
const endpoint = "/api/beszel/user-container-alerts"
const alertDebounce = 100
const alertKeys = Object.keys(containerAlertInfo) as (keyof typeof containerAlertInfo)[]
const failedUpdateToast = (error: unknown) => {
console.error(error)
toast({
title: t`Failed to update alert`,
description: t`Please check logs for more details.`,
variant: "destructive",
})
}
/** Create or update container alerts */
const upsertContainerAlerts = debounce(
async ({
name,
value,
min,
systems,
containers,
}: {
name: string
value: number
min: number
systems: string[]
containers: string[]
}) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "POST",
body: { name, value, min, systems, containers, overwrite: true },
})
} catch (error) {
failedUpdateToast(error)
}
},
alertDebounce
)
/** Delete container alerts */
const deleteContainerAlerts = debounce(
async ({ name, systems, containers }: { name: string; systems: string[]; containers: string[] }) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems, containers },
})
} catch (error) {
failedUpdateToast(error)
}
},
alertDebounce
)
export const ContainerAlertDialogContent = memo(function ContainerAlertDialogContent({
systemId,
container,
}: {
systemId: string
container: ContainerRecord
}) {
const alerts = useStore($containerAlerts)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("container")
const containerAlerts = alerts[systemId]?.get(container.id) ?? new Map()
// Keep a copy of alerts when we switch to global tab
const alertsWhenGlobalSelected = useMemo(() => {
return currentTab === "global" ? structuredClone(alerts) : alerts
}, [currentTab])
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
<Trans>Container Alerts</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
See{" "}
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="container" onValueChange={setCurrentTab}>
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="container">
<BoxIcon className="me-2 h-3.5 w-3.5" />
<span className="truncate max-w-60">{container.name}</span>
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Containers</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="container">
<div className="grid gap-3">
{alertKeys.map((name) => (
<ContainerAlertContent
key={name}
alertKey={name}
data={containerAlertInfo[name as keyof typeof containerAlertInfo]}
alert={containerAlerts.get(name)}
systemId={systemId}
container={container}
/>
))}
</div>
</TabsContent>
<TabsContent value="global">
<label
htmlFor="ovw"
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
>
<Checkbox
id="ovw"
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
/>
<Trans>Overwrite existing alerts</Trans>
</label>
<div className="grid gap-3">
{alertKeys.map((name) => (
<ContainerAlertContent
key={name}
alertKey={name}
systemId={systemId}
container={container}
alert={containerAlerts.get(name)}
data={containerAlertInfo[name as keyof typeof containerAlertInfo]}
global={true}
overwriteExisting={!!overwriteExisting}
initialAlertsState={alertsWhenGlobalSelected}
/>
))}
</div>
</TabsContent>
</Tabs>
</>
)
})
export function ContainerAlertContent({
alertKey,
data: alertData,
systemId,
container,
alert,
global = false,
overwriteExisting = false,
initialAlertsState = {},
}: {
alertKey: string
data: ContainerAlertInfo
systemId: string
container: ContainerRecord
alert?: ContainerAlertRecord
global?: boolean
overwriteExisting?: boolean
initialAlertsState?: Record<string, Map<string, Map<string, ContainerAlertRecord>>>
}) {
const { name } = alertData
const singleDescription = alertData.singleDesc?.()
const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || 10)
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
const Icon = alertData.icon
/** Get container ids to update */
function getContainerIds(): string[] {
// if not global, update only the current container
if (!global) {
return [container.id]
}
// if global, we need to get all containers for this system
// For now, we'll just use the current container
// In a real implementation, you'd fetch all containers for the system
return [container.id]
}
function sendUpsert(min: number, value: number) {
const containers = getContainerIds()
containers.length &&
upsertContainerAlerts({
name: alertKey,
value,
min,
systems: [systemId],
containers,
})
}
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`c${name}`}
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
"pb-0": checked,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
</p>
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
</div>
<Switch
id={`c${name}`}
checked={checked}
onCheckedChange={(newChecked) => {
setChecked(newChecked)
if (newChecked) {
// if alert checked, create or update alert
sendUpsert(min, value)
} else {
// if unchecked, delete alert
deleteContainerAlerts({ name: alertKey, systems: [systemId], containers: getContainerIds() })
// when force deleting all alerts of a type, also remove them from initialAlertsState
if (overwriteExisting) {
for (const systemAlerts of Object.values(initialAlertsState)) {
for (const containerAlerts of systemAlerts.values()) {
containerAlerts.delete(alertKey)
}
}
}
}
}}
/>
</label>
{checked && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
{!singleDescription && (
<div>
<p id={`v${name}`} className="text-sm block h-8">
{alertData.invert ? (
<Trans>
Average drops below{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
) : (
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
)}
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
/>
</div>
</div>
)}
<div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
{singleDescription && (
<>
{singleDescription}
{` `}
</>
)}
<Trans>
For <strong className="text-foreground">{min}</strong>{" "}
<Plural value={min} one="minute" other="minutes" />
</Trans>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[min]}
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { lazy, Suspense } from "react"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
@@ -40,6 +41,9 @@ function getStatusValue(status: string): number {
return 0
}
// Lazy load the alert button component
const ContainerAlertButton = lazy(() => import("@/components/alerts/container-alert-button"))
export const containerChartCols: ColumnDef<ContainerRecord>[] = [
{
id: "name",
@@ -62,7 +66,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
return <span className="ms-1.5 xl:w-30 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
// {
@@ -137,17 +141,17 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
accessorFn: (record) => record.image,
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
),
enableHiding: true,
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span>
return <span className="ms-1.5 xl:w-30 block truncate">{getValue() as string}</span>
},
},
{
id: "status",
accessorFn: (record) => record.status,
invertSorting: true,
enableHiding: true,
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
cell: ({ getValue }) => {
@@ -164,6 +168,21 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
},
},
{
id: "actions",
header: () => <div className="text-center">{t`Actions`}</div>,
cell: ({ row }) => {
// Lazy load the alert button component
const ContainerAlertButton = lazy(() => import("@/components/alerts/container-alert-button"))
return (
<div className="flex justify-center" onClick={(e) => e.stopPropagation()}>
<Suspense fallback={<div className="h-8 w-8" />}>
<ContainerAlertButton systemId={row.original.system} container={row.original} />
</Suspense>
</div>
)
},
},
]
function HeaderButton({

View File

@@ -231,7 +231,7 @@ const AllContainersTable = memo(function AllContainersTable({
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<table className="text-sm w-full h-full">
<ContainersTableHead table={table} />
<TableBody>
{rows.length ? (

View File

@@ -3,7 +3,7 @@ import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { $alerts, $allSystemsById, $allSystemsByName, $containerAlerts, $userSettings } from "./stores"
import { chartTimeData } from "./utils"
/** PocketBase JS Client */
@@ -30,6 +30,7 @@ export function logOut() {
$allSystemsByName.set({})
$allSystemsById.set({})
$alerts.set({})
$containerAlerts.set({})
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear()

View File

@@ -0,0 +1,180 @@
import { t } from "@lingui/core/macro"
import { CpuIcon, HeartPulseIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon } from "@/components/ui/icons"
import { $containerAlerts } from "@/lib/stores"
import type { ContainerAlertInfo, ContainerAlertRecord } from "@/types"
import { pb } from "./api"
/** Container alert info for each alert type */
export const containerAlertInfo: Record<string, ContainerAlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when container status changes`,
singleDesc: () => `${t`Container`} ${t`Stopped`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Network: {
name: () => t`Network`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
},
Health: {
name: () => t`Health Status`,
unit: "",
icon: HeartPulseIcon,
desc: () => t`Triggers when container health status changes`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
},
}
class ContainerAlertManager {
private unsubscribeFn?: () => void
/**
* Subscribe to container alert updates
*/
async subscribe() {
if (this.unsubscribeFn) {
return
}
// Fetch initial container alerts
try {
const alerts = await pb.collection("container_alerts").getFullList<ContainerAlertRecord>()
this.updateStore(alerts)
} catch (e) {
console.error("Failed to fetch container alerts:", e)
}
// Subscribe to real-time updates
this.unsubscribeFn = await pb
.collection("container_alerts")
.subscribe<ContainerAlertRecord>("*", this.handleAlertUpdate)
}
/**
* Unsubscribe from container alert updates
*/
unsubscribe() {
if (this.unsubscribeFn) {
this.unsubscribeFn()
this.unsubscribeFn = undefined
}
}
/**
* Handle real-time alert updates
*/
private handleAlertUpdate = (e: RecordSubscription<ContainerAlertRecord>) => {
const { action, record } = e
if (action === "delete") {
this.deleteFromStore(record)
} else {
this.updateStore([record])
}
}
/**
* Update store with alert records
*/
private updateStore(alerts: ContainerAlertRecord[]) {
const currentAlerts = $containerAlerts.get()
for (const alert of alerts) {
if (!currentAlerts[alert.system]) {
currentAlerts[alert.system] = new Map()
}
if (!currentAlerts[alert.system].get(alert.container)) {
currentAlerts[alert.system].set(alert.container, new Map())
}
currentAlerts[alert.system].get(alert.container)!.set(alert.name, alert)
}
$containerAlerts.set(currentAlerts)
}
/**
* Delete alert from store
*/
private deleteFromStore(alert: ContainerAlertRecord) {
const currentAlerts = $containerAlerts.get()
if (currentAlerts[alert.system]?.get(alert.container)?.has(alert.name)) {
currentAlerts[alert.system].get(alert.container)!.delete(alert.name)
// Clean up empty maps
if (currentAlerts[alert.system].get(alert.container)!.size === 0) {
currentAlerts[alert.system].delete(alert.container)
}
if (currentAlerts[alert.system].size === 0) {
delete currentAlerts[alert.system]
}
$containerAlerts.set(currentAlerts)
}
}
/**
* Create or update container alerts
*/
async upsert(
systems: string[],
containers: string[],
name: string,
value: number,
min: number,
overwrite = false
) {
return pb.send("/api/beszel/user-container-alerts", {
method: "POST",
body: {
systems,
containers,
name,
value,
min,
overwrite,
},
})
}
/**
* Delete container alerts
*/
async delete(systems: string[], containers: string[], name: string) {
return pb.send("/api/beszel/user-container-alerts", {
method: "DELETE",
body: {
systems,
containers,
name,
},
})
}
/**
* Clear all container alerts from store
*/
clear() {
$containerAlerts.set({})
}
}
export const containerAlertManager = new ContainerAlertManager()

View File

@@ -1,5 +1,5 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import type { AlertMap, ChartTimes, ContainerAlertMap, SystemRecord, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
@@ -22,6 +22,9 @@ export const $systems: ReadableAtom<SystemRecord[]> = computed($allSystemsById,
/** Map of alert records by system id and alert name */
export const $alerts = map<AlertMap>({})
/** Map of container alert records by system id, container id, and alert name */
export const $containerAlerts = map<ContainerAlertMap>({})
/** SSH public key */
export const $publicKey = atom("")

View File

@@ -12,6 +12,7 @@ import Settings from "@/components/routes/settings/layout.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Toaster } from "@/components/ui/toaster.tsx"
import { alertManager } from "@/lib/alerts"
import { containerAlertManager } from "@/lib/container-alerts"
import { pb, updateUserSettings } from "@/lib/api.ts"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import { $authenticated, $copyContent, $direction, $publicKey, $userSettings } from "@/lib/stores.ts"
@@ -49,8 +50,11 @@ const App = memo(() => {
.then(alertManager.refresh)
// subscribe to new alert updates
.then(alertManager.subscribe)
// subscribe to container alerts
.then(() => containerAlertManager.subscribe())
return () => {
alertManager.unsubscribe()
containerAlertManager.unsubscribe()
systemsManager.unsubscribe()
}
}, [])

View File

@@ -340,6 +340,33 @@ export interface AlertInfo {
export type AlertMap = Record<string, Map<string, AlertRecord>>
export interface ContainerAlertRecord extends RecordModel {
id: string
system: string
container: string
name: string
triggered: boolean
value: number
min: number
}
export interface ContainerAlertInfo {
name: () => string
unit: string
icon: any
desc: () => string
max?: number
min?: number
step?: number
start?: number
/** Single value description (when there's only one value, like status) */
singleDesc?: () => string
invert?: boolean
}
export type ContainerAlertMap = Record<string, Map<string, Map<string, ContainerAlertRecord>>>
export interface SmartData {
/** model family */
// mf?: string