mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-05 20:03:48 +00:00
update notification service
This commit is contained in:
@@ -1,103 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
const CHANNELS = [
|
||||
{ key: "telegram", label: "Telegram", icon: "/icons/telegram.svg", color: "blue", switchOn: "bg-blue-600" },
|
||||
{ key: "gotify", label: "Gotify", icon: "/icons/gotify.svg", color: "green", switchOn: "bg-green-600" },
|
||||
{ key: "discord", label: "Discord", icon: "/icons/discord.svg", color: "indigo", switchOn: "bg-indigo-600" },
|
||||
{ key: "email", label: "Email", icon: "/icons/mail.svg", color: "amber", switchOn: "bg-amber-600" },
|
||||
]
|
||||
|
||||
const SELECTED_BORDER = {
|
||||
blue: "border-blue-500/60 bg-blue-500/10",
|
||||
green: "border-green-500/60 bg-green-500/10",
|
||||
indigo: "border-indigo-500/60 bg-indigo-500/10",
|
||||
amber: "border-amber-500/60 bg-amber-500/10",
|
||||
}
|
||||
|
||||
interface ChannelGridProps {
|
||||
enabledChannels: { telegram: boolean; gotify: boolean; discord: boolean; email: boolean }
|
||||
onToggle: (channel: string, enabled: boolean) => void
|
||||
selectedChannel: string | null
|
||||
onSelect: (channel: string | null) => void
|
||||
}
|
||||
|
||||
export function ChannelGrid({ enabledChannels, onToggle, selectedChannel, onSelect }: ChannelGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{CHANNELS.map(ch => {
|
||||
const isEnabled = enabledChannels[ch.key as keyof typeof enabledChannels] || false
|
||||
const isSelected = selectedChannel === ch.key
|
||||
const selStyle = SELECTED_BORDER[ch.color as keyof typeof SELECTED_BORDER]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={ch.key}
|
||||
type="button"
|
||||
onClick={() => onSelect(isSelected ? null : ch.key)}
|
||||
className={
|
||||
"group relative flex flex-col items-center justify-center gap-2 rounded-lg border p-4 transition-all cursor-pointer " +
|
||||
(isSelected
|
||||
? selStyle + " ring-1 ring-offset-0"
|
||||
: isEnabled
|
||||
? "border-border/60 bg-muted/30 hover:bg-muted/40"
|
||||
: "border-border/30 bg-muted/10 hover:border-border/50 hover:bg-muted/20")
|
||||
}
|
||||
>
|
||||
{/* Status dot */}
|
||||
{isEnabled && (
|
||||
<span className={"absolute top-2 right-2 h-1.5 w-1.5 rounded-full " + ch.switchOn} />
|
||||
)}
|
||||
|
||||
{/* Logo */}
|
||||
<img
|
||||
src={ch.icon}
|
||||
alt={ch.label}
|
||||
className={
|
||||
"h-7 w-7 transition-opacity " +
|
||||
(isEnabled || isSelected ? "opacity-100" : "opacity-30 group-hover:opacity-70")
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className={
|
||||
"text-[11px] font-medium transition-colors " +
|
||||
(isEnabled || isSelected ? "text-foreground" : "text-muted-foreground/60")
|
||||
}
|
||||
>
|
||||
{ch.label}
|
||||
</span>
|
||||
|
||||
{/* Hover overlay with switch */}
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 flex items-center justify-center py-1.5 rounded-b-lg bg-background/80 backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(ch.key, !isEnabled)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"relative w-8 h-4 rounded-full transition-colors " +
|
||||
(isEnabled ? ch.switchOn : "bg-muted-foreground/30")
|
||||
}
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
aria-label={"Enable " + ch.label}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"absolute top-[2px] left-[2px] h-3 w-3 rounded-full bg-white shadow transition-transform " +
|
||||
(isEnabled ? "translate-x-4" : "translate-x-0")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { ChannelGrid } from "./channel-grid"
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Badge } from "./ui/badge"
|
||||
@@ -143,7 +143,6 @@ export function NotificationSettings() {
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [selectedChannel, setSelectedChannel] = useState<string | null>(null)
|
||||
const [originalConfig, setOriginalConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [webhookSetup, setWebhookSetup] = useState<{
|
||||
status: "idle" | "running" | "success" | "failed"
|
||||
@@ -671,22 +670,41 @@ matcher: proxmenux-pbs
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Channels</span>
|
||||
</div>
|
||||
|
||||
<ChannelGrid
|
||||
enabledChannels={{
|
||||
telegram: config.channels.telegram?.enabled || false,
|
||||
gotify: config.channels.gotify?.enabled || false,
|
||||
discord: config.channels.discord?.enabled || false,
|
||||
email: config.channels.email?.enabled || false,
|
||||
}}
|
||||
onToggle={(ch, val) => updateChannel(ch, "enabled", val)}
|
||||
selectedChannel={selectedChannel}
|
||||
onSelect={setSelectedChannel}
|
||||
/>
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20 p-3">
|
||||
<Tabs defaultValue="telegram" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-4 h-8">
|
||||
<TabsTrigger value="telegram" className="text-xs data-[state=active]:text-blue-500">
|
||||
Telegram
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="gotify" className="text-xs data-[state=active]:text-green-500">
|
||||
Gotify
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="discord" className="text-xs data-[state=active]:text-indigo-500">
|
||||
Discord
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="email" className="text-xs data-[state=active]:text-amber-500">
|
||||
Email
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Telegram Config ── */}
|
||||
{selectedChannel === "telegram" && (
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/5 p-3 space-y-3 mt-3">
|
||||
{config.channels.telegram?.enabled ? (
|
||||
{/* Telegram */}
|
||||
<TabsContent value="telegram" className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Enable Telegram</Label>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.channels.telegram?.enabled ? "bg-blue-600" : "bg-muted-foreground/30"
|
||||
} cursor-pointer`}
|
||||
onClick={() => updateChannel("telegram", "enabled", !config.channels.telegram?.enabled)}
|
||||
role="switch"
|
||||
aria-checked={config.channels.telegram?.enabled || false}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.channels.telegram?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{config.channels.telegram?.enabled && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Bot Token</Label>
|
||||
@@ -715,6 +733,7 @@ matcher: proxmenux-pbs
|
||||
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Per-channel action bar */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
@@ -734,16 +753,27 @@ matcher: proxmenux-pbs
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">Enable Telegram using the switch on hover to configure it.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Gotify Config ── */}
|
||||
{selectedChannel === "gotify" && (
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/5 p-3 space-y-3 mt-3">
|
||||
{config.channels.gotify?.enabled ? (
|
||||
{/* Gotify */}
|
||||
<TabsContent value="gotify" className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Enable Gotify</Label>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.channels.gotify?.enabled ? "bg-green-600" : "bg-muted-foreground/30"
|
||||
} cursor-pointer`}
|
||||
onClick={() => updateChannel("gotify", "enabled", !config.channels.gotify?.enabled)}
|
||||
role="switch"
|
||||
aria-checked={config.channels.gotify?.enabled || false}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.channels.gotify?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{config.channels.gotify?.enabled && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Server URL</Label>
|
||||
@@ -772,6 +802,7 @@ matcher: proxmenux-pbs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Per-channel action bar */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-green-600 hover:bg-green-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
@@ -791,16 +822,27 @@ matcher: proxmenux-pbs
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">Enable Gotify using the switch on hover to configure it.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Discord Config ── */}
|
||||
{selectedChannel === "discord" && (
|
||||
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-3 space-y-3 mt-3">
|
||||
{config.channels.discord?.enabled ? (
|
||||
{/* Discord */}
|
||||
<TabsContent value="discord" className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Enable Discord</Label>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.channels.discord?.enabled ? "bg-indigo-600" : "bg-muted-foreground/30"
|
||||
} cursor-pointer`}
|
||||
onClick={() => updateChannel("discord", "enabled", !config.channels.discord?.enabled)}
|
||||
role="switch"
|
||||
aria-checked={config.channels.discord?.enabled || false}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.channels.discord?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{config.channels.discord?.enabled && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Webhook URL</Label>
|
||||
@@ -820,6 +862,7 @@ matcher: proxmenux-pbs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Per-channel action bar */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-indigo-600 hover:bg-indigo-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
@@ -839,16 +882,27 @@ matcher: proxmenux-pbs
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">Enable Discord using the switch on hover to configure it.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Email Config ── */}
|
||||
{selectedChannel === "email" && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 space-y-3 mt-3">
|
||||
{config.channels.email?.enabled ? (
|
||||
{/* Email */}
|
||||
<TabsContent value="email" className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Enable Email</Label>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.channels.email?.enabled ? "bg-amber-600" : "bg-muted-foreground/30"
|
||||
} cursor-pointer`}
|
||||
onClick={() => updateChannel("email", "enabled", !config.channels.email?.enabled)}
|
||||
role="switch"
|
||||
aria-checked={config.channels.email?.enabled || false}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.channels.email?.enabled ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{config.channels.email?.enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
@@ -949,6 +1003,7 @@ matcher: proxmenux-pbs
|
||||
For Gmail, use an App Password instead of your account password.
|
||||
</p>
|
||||
</div>
|
||||
{/* Per-channel action bar */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
className="h-7 px-3 text-xs rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
@@ -968,11 +1023,9 @@ matcher: proxmenux-pbs
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">Enable Email using the switch on hover to configure it.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
@@ -989,6 +1042,7 @@ matcher: proxmenux-pbs
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* close bordered channel container */}
|
||||
</div>
|
||||
|
||||
{/* ── Filters ── */}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><defs><style>.a{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style></defs><path class="a" d="M17.59,34.1733c-.89,1.3069-1.8944,2.6152-2.91,3.8267C7.3,37.79,4.5,33,4.5,33A44.83,44.83,0,0,1,9.31,13.48,16.47,16.47,0,0,1,18.69,10l1,2.31A32.6875,32.6875,0,0,1,24,12a32.9643,32.9643,0,0,1,4.33.3l1-2.31a16.47,16.47,0,0,1,9.38,3.51A44.8292,44.8292,0,0,1,43.5,33s-2.8,4.79-10.18,5a47.4193,47.4193,0,0,1-2.86-3.81m6.46-2.9c-3.84,1.9454-7.5555,3.89-12.92,3.89s-9.08-1.9446-12.92-3.89"/><circle class="a" cx="17.847" cy="26.23" r="3.35"/><circle class="a" cx="30.153" cy="26.23" r="3.35"/></svg>
|
||||
|
Before Width: | Height: | Size: 819 B |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><defs><style>.a{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style></defs><path class="a" d="M32.6319,35.4058c-1.59,2.4228-3.29,5.0352-7.0635,6.1748-3.7828,1.13-9.7585.9672-13.4072-.7183C8.503,39.1673,7.1718,35.94,7.8518,33.4405c.67-2.509,3.3422-4.3,4.5488-6.2247,1.1971-1.9345.929-4.003.6129-5.9183a27.9877,27.9877,0,0,1-.6129-5.0947A10.325,10.325,0,0,1,13.43,12.4655"/><path class="a" d="M14.5553,12.6116c-6.9429-.4788-1.4364-6.7036.9577-1.9153"/><path class="a" d="M15.1814,10.1307c4.5488-5.2671,21.4-6.7413,23.0758,6.6658"/><path class="a" d="M19.8224,7.2726c.1844-1.9982,1.2165-2.1541,2.616-.6717"/><path class="a" d="M38.2572,16.7965a1.214,1.214,0,0,1,1.312,1.0822,1.3446,1.3446,0,0,1-2.6335,0A1.2174,1.2174,0,0,1,38.2572,16.7965Z"/><path class="a" d="M37.06,18.3575c-5.5069,3.9549,5.3813,4.1232,2.509-.3855"/><path class="a" d="M28.6807,11.1751a5.7459,5.7459,0,1,1-5.7459,5.7459A5.7443,5.7443,0,0,1,28.6807,11.1751Z"/><path class="a" d="M30.596,15.724a1.3227,1.3227,0,1,1-1.197,1.3119A1.2566,1.2566,0,0,1,30.596,15.724Z"/><path class="a" d="M38.4967,21.148v1.7846"/><path class="a" d="M36.29,11.2939c2.2266-.7383,4.4755,2.8711,3.03,5.9511"/><path class="a" d="M26.9282,25.4537,40.0672,22.59a1.3982,1.3982,0,0,1,1.665,1.0666l.0013.006,1.7334,7.9485a1.3982,1.3982,0,0,1-1.063,1.6663L29.2553,36.1411a1.3981,1.3981,0,0,1-1.665-1.0665l-.0013-.0061L25.8556,27.12a1.3983,1.3983,0,0,1,1.0666-1.665Z"/><path class="a" d="M8.8844,31.3775c-5.77-2.4511-1.072-5.0228.8284-1.03"/><path class="a" d="M4.5,37.9894q3.1124,2.8729,5.0277-.7183"/><path class="a" d="M30.9815,37.7684c1.6762-.389,2.225.73,1.53,3.3333"/><path class="a" d="M23.1742,26.4975q1.1972,2.2889,3.8306.1341"/><path class="a" d="M26.0472,26.9764l9.0977,3.9647,6.2938-7.8819"/><path class="a" d="M28.4789,36.09l4.74-5.9877"/><path class="a" d="M36.56,29.1694l6.6911,3.5083"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 48 48" id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style></defs><path class="cls-1" d="M6.47,10.71a2,2,0,0,0-2,2h0V35.32a2,2,0,0,0,2,2H41.53a2,2,0,0,0,2-2h0V12.68a2,2,0,0,0-2-2H6.47Zm33.21,3.82L24,26.07,8.32,14.53"/></svg>
|
||||
|
Before Width: | Height: | Size: 504 B |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 48 48" id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style></defs><path class="cls-1" d="M40.83,8.48c1.14,0,2,1,1.54,2.86l-5.58,26.3c-.39,1.87-1.52,2.32-3.08,1.45L20.4,29.26a.4.4,0,0,1,0-.65L35.77,14.73c.7-.62-.15-.92-1.07-.36L15.41,26.54a.46.46,0,0,1-.4.05L6.82,24C5,23.47,5,22.22,7.23,21.33L40,8.69a2.16,2.16,0,0,1,.83-.21Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 614 B |
@@ -115,6 +115,14 @@ class JournalWatcher:
|
||||
# 24h anti-cascade for disk I/O + filesystem errors (keyed by device name)
|
||||
self._disk_io_notified: Dict[str, float] = {}
|
||||
self._DISK_IO_COOLDOWN = 86400 # 24 hours
|
||||
|
||||
# Track when the last full backup job notification was sent
|
||||
# so we can suppress per-guest "Starting Backup of VM ..." noise
|
||||
self._last_backup_job_ts: float = 0
|
||||
self._BACKUP_JOB_SUPPRESS_WINDOW = 7200 # 2h: suppress per-guest during active job
|
||||
|
||||
# NOTE: Service failure batching is handled universally by
|
||||
# BurstAggregator in NotificationManager (AGGREGATION_RULES).
|
||||
|
||||
def start(self):
|
||||
"""Start the journal watcher thread."""
|
||||
@@ -521,42 +529,26 @@ class JournalWatcher:
|
||||
match = re.search(pattern, msg)
|
||||
if match:
|
||||
service_name = match.group(1)
|
||||
data = {
|
||||
'service_name': service_name,
|
||||
'reason': msg[:300],
|
||||
'hostname': self._hostname,
|
||||
}
|
||||
display_name = service_name
|
||||
|
||||
# Enrich PVE VM/CT services with guest name and context
|
||||
# pve-container@101 -> LXC container 101
|
||||
# qemu-server@100 -> QEMU VM 100
|
||||
pve_match = re.match(
|
||||
r'(pve-container|qemu-server)@(\d+)', service_name)
|
||||
if pve_match:
|
||||
svc_type = pve_match.group(1)
|
||||
vmid = pve_match.group(2)
|
||||
vm_name = self._resolve_vm_name(vmid)
|
||||
|
||||
if svc_type == 'pve-container':
|
||||
guest_type = 'LXC container'
|
||||
else:
|
||||
guest_type = 'QEMU VM'
|
||||
|
||||
display = f"{guest_type} {vmid}"
|
||||
if vm_name:
|
||||
display = f"{guest_type} {vmid} ({vm_name})"
|
||||
|
||||
data['service_name'] = service_name
|
||||
data['vmid'] = vmid
|
||||
data['vmname'] = vm_name
|
||||
data['guest_type'] = guest_type
|
||||
data['display_name'] = display
|
||||
data['reason'] = (
|
||||
f"{display} failed to start.\n{msg[:300]}"
|
||||
)
|
||||
guest_type = 'CT' if svc_type == 'pve-container' else 'VM'
|
||||
display_name = f"{guest_type} {vm_name} ({vmid})" if vm_name else f"{guest_type} {vmid}"
|
||||
|
||||
self._emit('service_fail', 'WARNING', data,
|
||||
entity='node', entity_id=service_name)
|
||||
# Emit directly -- the BurstAggregator in NotificationManager
|
||||
# will automatically batch multiple service failures that
|
||||
# arrive within the aggregation window (90s).
|
||||
self._emit('service_fail', 'WARNING', {
|
||||
'service_name': display_name,
|
||||
'reason': msg[:300],
|
||||
'hostname': self._hostname,
|
||||
}, entity='node', entity_id=service_name)
|
||||
return
|
||||
|
||||
def _resolve_vm_name(self, vmid: str) -> str:
|
||||
@@ -765,11 +757,17 @@ class JournalWatcher:
|
||||
# Fallback: vzdump also emits per-guest messages like:
|
||||
# "INFO: Starting Backup of VM 104 (lxc)"
|
||||
# These fire for EACH guest when a multi-guest vzdump job runs.
|
||||
# We only use this if the primary pattern didn't match.
|
||||
# We SUPPRESS these when a full backup job was recently notified
|
||||
# (within 2h window) to avoid spamming one notification per guest.
|
||||
# Only use fallback for standalone single-VM backups (manual, no job).
|
||||
fallback_guest = None
|
||||
if not match:
|
||||
fb = re.match(r'(?:INFO:\s*)?Starting Backup of VM (\d+)\s+\((lxc|qemu)\)', msg)
|
||||
if fb:
|
||||
# If a full job notification was sent recently, suppress per-guest noise
|
||||
now = time.time()
|
||||
if now - self._last_backup_job_ts < self._BACKUP_JOB_SUPPRESS_WINDOW:
|
||||
return # Part of an active job -- already notified
|
||||
fallback_guest = fb.group(1)
|
||||
else:
|
||||
return
|
||||
@@ -809,16 +807,21 @@ class JournalWatcher:
|
||||
|
||||
if guests:
|
||||
if guests == ['all']:
|
||||
reason_parts.append('Guests: All VMs/CTs')
|
||||
reason_parts.append('VM/CT: All')
|
||||
else:
|
||||
guest_lines = []
|
||||
for gid in guests:
|
||||
gname = self._resolve_vm_name(gid)
|
||||
if gname:
|
||||
guest_lines.append(f' {gname} ({gid})')
|
||||
# Skip non-guest IDs (0, 1 are not real guests)
|
||||
if gid in ('0', '1'):
|
||||
continue
|
||||
info = self._resolve_vm_info(gid)
|
||||
if info:
|
||||
gname, gtype = info
|
||||
guest_lines.append(f' {gtype} {gname} ({gid})')
|
||||
else:
|
||||
guest_lines.append(f' ID {gid}')
|
||||
reason_parts.append('Guests:\n' + '\n'.join(guest_lines))
|
||||
if guest_lines:
|
||||
reason_parts.append('VM/CT:\n' + '\n'.join(guest_lines))
|
||||
|
||||
details = []
|
||||
if storage:
|
||||
@@ -837,6 +840,11 @@ class JournalWatcher:
|
||||
# dedup each other, while the SAME job doesn't fire twice.
|
||||
guest_key = '_'.join(sorted(guests)) if guests else 'unknown'
|
||||
|
||||
# If this was a full job (primary pattern), record timestamp to
|
||||
# suppress subsequent per-guest "Starting Backup of VM" messages
|
||||
if match:
|
||||
self._last_backup_job_ts = time.time()
|
||||
|
||||
self._emit('backup_start', 'INFO', {
|
||||
'vmid': ', '.join(guests),
|
||||
'vmname': '',
|
||||
@@ -847,18 +855,33 @@ class JournalWatcher:
|
||||
|
||||
def _resolve_vm_name(self, vmid: str) -> str:
|
||||
"""Try to resolve a VMID to its name from PVE config files."""
|
||||
info = self._resolve_vm_info(vmid)
|
||||
return info[0] if info else ''
|
||||
|
||||
def _resolve_vm_info(self, vmid: str):
|
||||
"""Resolve a VMID to (name, type) from PVE config files.
|
||||
|
||||
Returns tuple (name, 'VM'|'CT') or None if not found.
|
||||
type is determined by which config directory the ID was found in:
|
||||
/etc/pve/qemu-server -> VM
|
||||
/etc/pve/lxc -> CT
|
||||
"""
|
||||
if not vmid or not vmid.isdigit():
|
||||
return ''
|
||||
for base in ['/etc/pve/qemu-server', '/etc/pve/lxc']:
|
||||
return None
|
||||
type_map = [
|
||||
('/etc/pve/qemu-server', 'VM'),
|
||||
('/etc/pve/lxc', 'CT'),
|
||||
]
|
||||
for base, gtype in type_map:
|
||||
conf = f'{base}/{vmid}.conf'
|
||||
try:
|
||||
with open(conf, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('name:') or line.startswith('hostname:'):
|
||||
return line.split(':', 1)[1].strip()
|
||||
return (line.split(':', 1)[1].strip(), gtype)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
return ''
|
||||
return None
|
||||
|
||||
def _check_cluster_events(self, msg: str, syslog_id: str):
|
||||
"""Detect cluster split-brain and node disconnect."""
|
||||
|
||||
@@ -126,13 +126,23 @@ class GroupRateLimiter:
|
||||
|
||||
|
||||
AGGREGATION_RULES = {
|
||||
'auth_fail': {'window': 120, 'min_count': 3, 'burst_type': 'burst_auth_fail'},
|
||||
'ip_block': {'window': 120, 'min_count': 3, 'burst_type': 'burst_ip_block'},
|
||||
'disk_io_error': {'window': 60, 'min_count': 3, 'burst_type': 'burst_disk_io'},
|
||||
'split_brain': {'window': 300, 'min_count': 2, 'burst_type': 'burst_cluster'},
|
||||
'node_disconnect': {'window': 300, 'min_count': 2, 'burst_type': 'burst_cluster'},
|
||||
'auth_fail': {'window': 120, 'min_count': 3, 'burst_type': 'burst_auth_fail'},
|
||||
'ip_block': {'window': 120, 'min_count': 3, 'burst_type': 'burst_ip_block'},
|
||||
'disk_io_error': {'window': 60, 'min_count': 3, 'burst_type': 'burst_disk_io'},
|
||||
'split_brain': {'window': 300, 'min_count': 2, 'burst_type': 'burst_cluster'},
|
||||
'node_disconnect': {'window': 300, 'min_count': 2, 'burst_type': 'burst_cluster'},
|
||||
'service_fail': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
|
||||
'service_fail_batch': {'window': 90, 'min_count': 2, 'burst_type': 'burst_service_fail'},
|
||||
'system_problem': {'window': 90, 'min_count': 2, 'burst_type': 'burst_system'},
|
||||
'oom_kill': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
|
||||
'firewall_issue': {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'},
|
||||
}
|
||||
|
||||
# Default catch-all rule for any event type NOT listed above.
|
||||
# This ensures that even unlisted event types get grouped when they
|
||||
# burst, avoiding notification floods from any source.
|
||||
_DEFAULT_AGGREGATION = {'window': 60, 'min_count': 2, 'burst_type': 'burst_generic'}
|
||||
|
||||
|
||||
class BurstAggregator:
|
||||
"""Accumulates similar events in a time window, then sends a single summary.
|
||||
@@ -150,11 +160,13 @@ class BurstAggregator:
|
||||
def ingest(self, event: NotificationEvent) -> Optional[NotificationEvent]:
|
||||
"""Add event to aggregation. Returns:
|
||||
- None if event is being buffered (wait for window)
|
||||
- Original event if not eligible for aggregation
|
||||
- Original event if first in its bucket (sent immediately)
|
||||
|
||||
ALL event types are aggregated: specific rules from AGGREGATION_RULES
|
||||
take priority, otherwise the _DEFAULT_AGGREGATION catch-all applies.
|
||||
This prevents notification floods from any source.
|
||||
"""
|
||||
rule = AGGREGATION_RULES.get(event.event_type)
|
||||
if not rule:
|
||||
return event # Not aggregable, pass through
|
||||
rule = AGGREGATION_RULES.get(event.event_type, _DEFAULT_AGGREGATION)
|
||||
|
||||
bucket_key = f"{event.event_type}:{event.data.get('hostname', '')}"
|
||||
|
||||
@@ -202,7 +214,11 @@ class BurstAggregator:
|
||||
|
||||
def _create_summary(self, events: List[NotificationEvent],
|
||||
rule: dict) -> Optional[NotificationEvent]:
|
||||
"""Create a single summary event from multiple events."""
|
||||
"""Create a single summary event from multiple events.
|
||||
|
||||
Includes individual detail lines so the grouped message is
|
||||
self-contained and the user can see exactly what happened.
|
||||
"""
|
||||
if not events:
|
||||
return None
|
||||
|
||||
@@ -226,12 +242,32 @@ class BurstAggregator:
|
||||
|
||||
burst_type = rule.get('burst_type', 'burst_generic')
|
||||
|
||||
# Build detail lines from individual events.
|
||||
# For each event we extract the most informative field to show
|
||||
# a concise one-line summary (e.g. "- service_fail: pvestatd").
|
||||
detail_lines = []
|
||||
for ev in events[1:]: # Skip first (already sent individually)
|
||||
line = self._summarize_event(ev)
|
||||
if line:
|
||||
detail_lines.append(f" - {line}")
|
||||
|
||||
# Cap detail lines to avoid extremely long messages
|
||||
details = ''
|
||||
if detail_lines:
|
||||
if len(detail_lines) > 15:
|
||||
shown = detail_lines[:15]
|
||||
shown.append(f" ... +{len(detail_lines) - 15} more")
|
||||
details = '\n'.join(shown)
|
||||
else:
|
||||
details = '\n'.join(detail_lines)
|
||||
|
||||
data = {
|
||||
'hostname': first.data.get('hostname', socket.gethostname()),
|
||||
'count': str(len(events)),
|
||||
'window': window_str,
|
||||
'entity_list': entity_list,
|
||||
'event_type': first.event_type,
|
||||
'details': details,
|
||||
}
|
||||
|
||||
return NotificationEvent(
|
||||
@@ -242,6 +278,37 @@ class BurstAggregator:
|
||||
entity=first.entity,
|
||||
entity_id='burst',
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _summarize_event(event: NotificationEvent) -> str:
|
||||
"""Extract a concise one-line summary from an event's data."""
|
||||
d = event.data
|
||||
etype = event.event_type
|
||||
|
||||
# Service failures: show service name
|
||||
if etype in ('service_fail', 'service_fail_batch'):
|
||||
return d.get('service_name', d.get('display_name', etype))
|
||||
|
||||
# System problems: first 120 chars of reason
|
||||
if 'reason' in d:
|
||||
reason = d['reason'].split('\n')[0][:120]
|
||||
return reason
|
||||
|
||||
# Auth / IP: show username or IP
|
||||
if 'username' in d:
|
||||
return f"{etype}: {d['username']}"
|
||||
if 'ip' in d:
|
||||
return f"{etype}: {d['ip']}"
|
||||
|
||||
# VM/CT events: show vmid + name
|
||||
if 'vmid' in d:
|
||||
name = d.get('vmname', '')
|
||||
return f"{etype}: {name} ({d['vmid']})" if name else f"{etype}: {d['vmid']}"
|
||||
|
||||
# Fallback: event type + entity_id
|
||||
if event.entity_id:
|
||||
return f"{etype}: {event.entity_id}"
|
||||
return etype
|
||||
|
||||
|
||||
# ─── Notification Manager ─────────────────────────────────────────
|
||||
|
||||
@@ -73,6 +73,12 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]:
|
||||
filename = padded[col_starts[5]:].strip()
|
||||
|
||||
if vmid and vmid.isdigit():
|
||||
# Infer type from filename (vzdump-lxc-NNN or vzdump-qemu-NNN)
|
||||
vm_type = ''
|
||||
if 'lxc' in filename:
|
||||
vm_type = 'lxc'
|
||||
elif 'qemu' in filename:
|
||||
vm_type = 'qemu'
|
||||
vms.append({
|
||||
'vmid': vmid,
|
||||
'name': name,
|
||||
@@ -80,6 +86,7 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]:
|
||||
'time': time_val,
|
||||
'size': size,
|
||||
'filename': filename,
|
||||
'type': vm_type,
|
||||
})
|
||||
|
||||
# ── Strategy 2: log-style (PBS / Proxmox Backup Server) ──
|
||||
@@ -235,22 +242,49 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
status = vm.get('status', '').lower()
|
||||
icon = '\u2705' if status == 'ok' else '\u274C'
|
||||
|
||||
parts.append(f"{icon} ID {vm['vmid']} ({vm['name']})")
|
||||
# Determine VM/CT type prefix
|
||||
vm_type = vm.get('type', '')
|
||||
if vm_type == 'lxc':
|
||||
prefix = 'CT'
|
||||
elif vm_type == 'qemu':
|
||||
prefix = 'VM'
|
||||
else:
|
||||
# Try to infer from filename (vzdump-lxc-NNN or vzdump-qemu-NNN)
|
||||
fname = vm.get('filename', '')
|
||||
if 'lxc' in fname or fname.startswith('ct/'):
|
||||
prefix = 'CT'
|
||||
elif 'qemu' in fname or fname.startswith('vm/'):
|
||||
prefix = 'VM'
|
||||
else:
|
||||
prefix = ''
|
||||
|
||||
details = []
|
||||
# Format: "VM Name (ID)" or "CT Name (ID)" -- name first
|
||||
name = vm.get('name', '')
|
||||
vmid = vm.get('vmid', '')
|
||||
if prefix and name:
|
||||
parts.append(f"{icon} {prefix} {name} ({vmid})")
|
||||
elif name:
|
||||
parts.append(f"{icon} {name} ({vmid})")
|
||||
else:
|
||||
parts.append(f"{icon} ID {vmid}")
|
||||
|
||||
# Size and Duration on same line
|
||||
detail_line = []
|
||||
if vm.get('size'):
|
||||
details.append(f"Size: {vm['size']}")
|
||||
detail_line.append(f"Size: {vm['size']}")
|
||||
if vm.get('time'):
|
||||
details.append(f"Duration: {vm['time']}")
|
||||
detail_line.append(f"Duration: {vm['time']}")
|
||||
if detail_line:
|
||||
parts.append(' | '.join(detail_line))
|
||||
|
||||
# PBS/File on separate line
|
||||
if vm.get('filename'):
|
||||
fname = vm['filename']
|
||||
# PBS archives look like "ct/100/2026-..." or "vm/105/2026-..."
|
||||
if re.match(r'^(?:ct|vm)/\d+/', fname):
|
||||
details.append(f"PBS: {fname}")
|
||||
parts.append(f"PBS: {fname}")
|
||||
else:
|
||||
details.append(f"File: {fname}")
|
||||
if details:
|
||||
parts.append(' | '.join(details))
|
||||
parts.append(f"File: {fname}")
|
||||
|
||||
parts.append('') # blank line between VMs
|
||||
|
||||
# Summary
|
||||
@@ -583,6 +617,12 @@ TEMPLATES = {
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'service_fail_batch': {
|
||||
'title': '{hostname}: {service_count} services failed',
|
||||
'body': '{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'system_mail': {
|
||||
'title': '{hostname}: {pve_title}',
|
||||
'body': '{reason}',
|
||||
@@ -683,9 +723,21 @@ TEMPLATES = {
|
||||
'group': 'cluster',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'burst_service_fail': {
|
||||
'title': '{hostname}: {count} services failed in {window}',
|
||||
'body': '{count} service failures detected in {window}.\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'burst_system': {
|
||||
'title': '{hostname}: {count} system problems in {window}',
|
||||
'body': '{count} system problems detected in {window}.\n\nAdditional issues:\n{details}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'burst_generic': {
|
||||
'title': '{hostname}: {count} {event_type} events in {window}',
|
||||
'body': '{count} events of type {event_type} in {window}.\n{entity_list}',
|
||||
'body': '{count} events of type {event_type} in {window}.\n\nAdditional events:\n{details}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user