mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 08:57:48 +02:00
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:
@@ -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]
|
||||
)
|
||||
})
|
||||
327
internal/site/src/components/alerts/container-alerts-sheet.tsx
Normal file
327
internal/site/src/components/alerts/container-alerts-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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()
|
||||
|
||||
180
internal/site/src/lib/container-alerts.ts
Normal file
180
internal/site/src/lib/container-alerts.ts
Normal 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()
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
27
internal/site/src/types.d.ts
vendored
27
internal/site/src/types.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user