feat(hub): copy existing alerts between systems (#1853)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Sven van Ginkel
2026-04-03 00:04:45 +02:00
committed by GitHub
parent 7f565a3086
commit afe9eb7a70

View File

@@ -2,11 +2,13 @@ import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro" import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { GlobeIcon, ServerIcon } from "lucide-react" import { ChevronDownIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { lazy, memo, Suspense, useMemo, useState } from "react" import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router" import { $router, Link } from "@/components/router"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -64,11 +66,57 @@ const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems:
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) { export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const systems = useStore($systems)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false) const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("system") const [currentTab, setCurrentTab] = useState("system")
// copyKey is used to force remount AlertContent components with
// new alert data after copying alerts from another system
const [copyKey, setCopyKey] = useState(0)
const systemAlerts = alerts[system.id] ?? new Map() const systemAlerts = alerts[system.id] ?? new Map()
// Systems that have at least one alert configured (excluding the current system)
const systemsWithAlerts = useMemo(
() => systems.filter((s) => s.id !== system.id && alerts[s.id]?.size),
[systems, alerts, system.id]
)
async function copyAlertsFromSystem(sourceSystemId: string) {
const sourceAlerts = $alerts.get()[sourceSystemId]
if (!sourceAlerts?.size) return
try {
const currentTargetAlerts = $alerts.get()[system.id] ?? new Map()
// Alert names present on target but absent from source should be deleted
const namesToDelete = Array.from(currentTargetAlerts.keys()).filter((name) => !sourceAlerts.has(name))
await Promise.all([
...Array.from(sourceAlerts.values()).map(({ name, value, min }) =>
pb.send<{ success: boolean }>(endpoint, {
method: "POST",
body: { name, value, min, systems: [system.id], overwrite: true },
requestKey: name,
})
),
...namesToDelete.map((name) =>
pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems: [system.id] },
requestKey: name,
})
),
])
// Optimistically update the store so components re-mount with correct data
// before the realtime subscription event arrives.
const newSystemAlerts = new Map<string, AlertRecord>()
for (const alert of sourceAlerts.values()) {
newSystemAlerts.set(alert.name, { ...alert, system: system.id, triggered: false })
}
$alerts.setKey(system.id, newSystemAlerts)
setCopyKey((k) => k + 1)
} catch (error) {
failedUpdateToast(error)
}
}
// We need to keep a copy of alerts when we switch to global tab. If we always compare to // We need to keep a copy of alerts when we switch to global tab. If we always compare to
// current alerts, it will only be updated when first checked, then won't be updated because // current alerts, it will only be updated when first checked, then won't be updated because
// after that it exists. // after that it exists.
@@ -93,18 +141,37 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="system" onValueChange={setCurrentTab}> <Tabs defaultValue="system" onValueChange={setCurrentTab}>
<TabsList className="mb-1 -mt-0.5"> <div className="flex items-center justify-between mb-1 -mt-0.5">
<TabsTrigger value="system"> <TabsList>
<ServerIcon className="me-2 h-3.5 w-3.5" /> <TabsTrigger value="system">
<span className="truncate max-w-60">{system.name}</span> <ServerIcon className="me-2 h-3.5 w-3.5" />
</TabsTrigger> <span className="truncate max-w-60">{system.name}</span>
<TabsTrigger value="global"> </TabsTrigger>
<GlobeIcon className="me-1.5 h-3.5 w-3.5" /> <TabsTrigger value="global">
<Trans>All Systems</Trans> <GlobeIcon className="me-1.5 h-3.5 w-3.5" />
</TabsTrigger> <Trans>All Systems</Trans>
</TabsList> </TabsTrigger>
</TabsList>
{systemsWithAlerts.length > 0 && currentTab === "system" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground text-xs gap-1.5">
<Trans context="Copy alerts from another system">Copy from</Trans>
<ChevronDownIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-100 overflow-auto">
{systemsWithAlerts.map((s) => (
<DropdownMenuItem key={s.id} className="min-w-44" onSelect={() => copyAlertsFromSystem(s.id)}>
{s.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TabsContent value="system"> <TabsContent value="system">
<div className="grid gap-3"> <div key={copyKey} className="grid gap-3">
{alertKeys.map((name) => ( {alertKeys.map((name) => (
<AlertContent <AlertContent
key={name} key={name}