update notification service

This commit is contained in:
MacRimi
2026-03-03 16:42:12 +01:00
parent da3f99a254
commit 58df4f1481
2 changed files with 176 additions and 341 deletions

View File

@@ -223,6 +223,148 @@ export function NotificationSettings() {
}))
}
/** Reusable 10+1 category block rendered inside each channel tab. */
const renderChannelCategories = (chName: string, accentColor: string) => {
const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} }
const evtByGroup = config.event_types_by_group || {}
return (
<div className="space-y-1.5 border-t border-border/30 pt-3 mt-3">
<div className="flex items-center gap-2 mb-2">
<Bell className="h-3 w-3 text-muted-foreground" />
<Label className="text-[11px] text-muted-foreground">Notification Categories</Label>
</div>
<div className="space-y-1">
{EVENT_CATEGORIES.map(cat => {
const isEnabled = overrides.categories[cat.key] ?? true
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
const eventsForGroup = evtByGroup[cat.key] || []
const enabledCount = eventsForGroup.filter(
e => (overrides.events?.[e.type] ?? e.default_enabled)
).length
return (
<div key={cat.key} className={`rounded-md border transition-colors ${
isEnabled ? `border-${accentColor}-500/30 bg-${accentColor}-500/5` : "border-border/50 bg-transparent"
}`}>
<div className="flex items-center gap-2 p-2">
<button
type="button"
className={`shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""} ${
!isEnabled ? "opacity-30 pointer-events-none" : "text-muted-foreground hover:text-foreground"
}`}
onClick={() => {
if (!isEnabled) return
setExpandedCategories(prev => {
const next = new Set(prev)
const key = `${chName}.${cat.key}`
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}}
aria-label={isExpanded ? "Collapse" : "Expand"}
>
<ChevronRight className="h-3 w-3" />
</button>
<div className="flex-1 min-w-0">
<span className={`text-[11px] font-medium block ${
isEnabled ? `text-${accentColor}-400` : "text-foreground"
}`}>{cat.label}</span>
<span className="text-[9px] text-muted-foreground">{cat.desc}</span>
</div>
{isEnabled && eventsForGroup.length > 0 && (
<span className="text-[9px] text-muted-foreground tabular-nums">
{enabledCount}/{eventsForGroup.length}
</span>
)}
<button
type="button"
role="switch"
aria-checked={isEnabled}
disabled={!editMode}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${isEnabled ? `bg-${accentColor}-600` : "bg-muted-foreground/30"}`}
onClick={() => {
if (!editMode) return
updateConfig(p => {
const ch = { ...(p.channel_overrides?.[chName] || { categories: {}, events: {} }) }
const newEnabled = !isEnabled
const newEvents = { ...(ch.events || {}) }
// When enabling, turn all sub-events on
if (newEnabled && eventsForGroup.length > 0) {
for (const evt of eventsForGroup) {
newEvents[evt.type] = true
}
}
return {
...p,
channel_overrides: {
...p.channel_overrides,
[chName]: { categories: { ...ch.categories, [cat.key]: newEnabled }, events: newEvents },
},
}
})
}}
>
<span className={`pointer-events-none block h-3 w-3 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? "translate-x-3.5" : "translate-x-0.5"
}`} />
</button>
</div>
{isEnabled && isExpanded && eventsForGroup.length > 0 && (
<div className="border-t border-border/30 px-2 py-1.5 space-y-0.5">
{eventsForGroup.map(evt => {
const evtEnabled = overrides.events?.[evt.type] ?? evt.default_enabled
return (
<div key={evt.type} className="flex items-center justify-between py-0.5 px-2 rounded hover:bg-muted/30 transition-colors">
<span className={`text-[10px] ${evtEnabled ? `text-${accentColor}-400` : "text-muted-foreground"}`}>
{evt.title}
</span>
<button
type="button"
role="switch"
aria-checked={evtEnabled}
disabled={!editMode}
className={`relative inline-flex h-3.5 w-6 shrink-0 items-center rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${evtEnabled ? `bg-${accentColor}-600` : "bg-muted-foreground/30"}`}
onClick={() => {
if (!editMode) return
updateConfig(p => {
const ch = { ...(p.channel_overrides?.[chName] || { categories: {}, events: {} }) }
return {
...p,
channel_overrides: {
...p.channel_overrides,
[chName]: { ...ch, events: { ...(ch.events || {}), [evt.type]: !evtEnabled } },
},
}
})
}}
>
<span className={`pointer-events-none block h-2.5 w-2.5 rounded-full bg-background shadow-sm transition-transform ${
evtEnabled ? "translate-x-3" : "translate-x-0.5"
}`} />
</button>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
/** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */
const flattenConfig = (cfg: NotificationConfig): Record<string, string> => {
const flat: Record<string, string> = {
@@ -244,28 +386,8 @@ export function NotificationSettings() {
flat[`${chName}.${field}`] = String(value ?? "")
}
}
// Flatten global event_categories: { vm_ct: true, backup: false } -> events.vm_ct, events.backup
for (const [cat, enabled] of Object.entries(cfg.event_categories)) {
flat[`events.${cat}`] = String(enabled)
}
// Flatten global event_toggles: { vm_start: true } -> event.vm_start
if (cfg.event_toggles) {
for (const [evt, enabled] of Object.entries(cfg.event_toggles)) {
flat[`event.${evt}`] = String(enabled)
}
}
// Write defaults for events NOT in toggles
if (cfg.event_types_by_group) {
for (const events of Object.values(cfg.event_types_by_group)) {
for (const evt of (events as Array<{type: string, default_enabled: boolean}>)) {
const key = `event.${evt.type}`
if (!(key in flat)) {
flat[key] = String(evt.default_enabled)
}
}
}
}
// Flatten per-channel overrides: telegram.events.backup, telegram.event.vm_start, etc.
// Per-channel category & event toggles: telegram.events.vm_ct, telegram.event.vm_start, etc.
// Each channel independently owns its notification preferences.
if (cfg.channel_overrides) {
for (const [chName, overrides] of Object.entries(cfg.channel_overrides)) {
if (overrides.categories) {
@@ -754,6 +876,7 @@ matcher: proxmenux-pbs
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
/>
</div>
{renderChannelCategories("telegram", "blue")}
{/* Per-channel action bar */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -823,6 +946,7 @@ matcher: proxmenux-pbs
</button>
</div>
</div>
{renderChannelCategories("gotify", "green")}
{/* Per-channel action bar */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -883,6 +1007,7 @@ matcher: proxmenux-pbs
</button>
</div>
</div>
{renderChannelCategories("discord", "indigo")}
{/* Per-channel action bar */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -1024,6 +1149,7 @@ matcher: proxmenux-pbs
For Gmail, use an App Password instead of your account password.
</p>
</div>
{renderChannelCategories("email", "amber")}
{/* Per-channel action bar */}
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
<button
@@ -1066,255 +1192,6 @@ matcher: proxmenux-pbs
</div>{/* close bordered channel container */}
</div>
{/* ── Filters ── */}
<div className="space-y-3 border-t border-border pt-4">
<div className="flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Filters & Events</span>
</div>
<div className="rounded-lg border border-border/50 bg-muted/20 p-3 space-y-4">
{/* Event Categories (global defaults -- per-channel overrides in Channel Filters below) */}
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">Event Categories</Label>
<div className="space-y-1.5">
{EVENT_CATEGORIES.map(cat => {
const isEnabled = config.event_categories[cat.key] ?? true
const isExpanded = expandedCategories.has(cat.key)
const eventsForGroup = config.event_types_by_group?.[cat.key] || []
const enabledCount = eventsForGroup.filter(e => config.event_toggles?.[e.type] ?? e.default_enabled).length
return (
<div key={cat.key} className={`rounded-md border transition-colors ${
isEnabled ? "border-green-500/30 bg-green-500/5" : "border-border/50 bg-transparent"
}`}>
{/* Category header row */}
<div className="flex items-center gap-2.5 p-2.5">
{/* Expand/collapse button */}
<button
type="button"
className={`shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""} ${
!isEnabled ? "opacity-30 pointer-events-none" : "text-muted-foreground hover:text-foreground"
}`}
onClick={() => {
if (!isEnabled) return
setExpandedCategories(prev => {
const next = new Set(prev)
if (next.has(cat.key)) next.delete(cat.key)
else next.add(cat.key)
return next
})
}}
aria-label={isExpanded ? "Collapse" : "Expand"}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
{/* Label + description */}
<div className="flex-1 min-w-0">
<span className={`text-xs font-medium block ${
isEnabled ? "text-green-400" : "text-foreground"
}`}>
{cat.label}
</span>
<span className="text-[10px] text-muted-foreground">{cat.desc}</span>
</div>
{/* Count badge */}
{isEnabled && eventsForGroup.length > 0 && (
<span className="text-[10px] text-muted-foreground tabular-nums">
{enabledCount}/{eventsForGroup.length}
</span>
)}
{/* Category toggle */}
<button
type="button"
role="switch"
aria-checked={isEnabled}
disabled={!editMode}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${isEnabled ? "bg-green-600" : "bg-muted-foreground/30"}`}
onClick={() => {
if (!editMode) return
const newEnabled = !isEnabled
updateConfig(p => {
const newToggles = { ...(p.event_toggles || {}) }
// When enabling a category, turn all its events on by default
if (newEnabled && eventsForGroup.length > 0) {
for (const evt of eventsForGroup) {
newToggles[evt.type] = true
}
}
return {
...p,
event_categories: { ...p.event_categories, [cat.key]: newEnabled },
event_toggles: newToggles,
}
})
}}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-background shadow-sm transition-transform ${
isEnabled ? "translate-x-4" : "translate-x-0.5"
}`} />
</button>
</div>
{/* Per-event toggles (expanded) */}
{isEnabled && isExpanded && eventsForGroup.length > 0 && (
<div className="border-t border-border/30 px-2.5 py-2 space-y-0.5">
{eventsForGroup.map(evt => {
const evtEnabled = config.event_toggles?.[evt.type] ?? evt.default_enabled
return (
<div key={evt.type} className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/30 transition-colors">
<span className={`text-[11px] ${evtEnabled ? "text-green-400" : "text-muted-foreground"}`}>
{evt.title}
</span>
<button
type="button"
role="switch"
aria-checked={evtEnabled}
disabled={!editMode}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${evtEnabled ? "bg-green-600" : "bg-muted-foreground/30"}`}
onClick={() => {
if (!editMode) return
updateConfig(p => ({
...p,
event_toggles: { ...(p.event_toggles || {}), [evt.type]: !evtEnabled },
}))
}}
>
<span className={`pointer-events-none block h-3 w-3 rounded-full bg-background shadow-sm transition-transform ${
evtEnabled ? "translate-x-3.5" : "translate-x-0.5"
}`} />
</button>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
{/* Per-channel overrides */}
<div className="space-y-2 border-t border-border/30 pt-3">
<Label className="text-[11px] text-muted-foreground">Channel Filters</Label>
<p className="text-[10px] text-muted-foreground leading-relaxed">
By default every channel inherits the global settings above. Override specific categories per channel to customize what each destination receives.
</p>
<div className="space-y-2">
{CHANNEL_TYPES.map(chName => {
const chEnabled = config.channels[chName]?.enabled
if (!chEnabled) return null
const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} }
const hasOverrides = Object.keys(overrides.categories).length > 0
const chLabel = chName === "email" ? "Email" : chName.charAt(0).toUpperCase() + chName.slice(1)
const chColor = chName === "telegram" ? "blue" : chName === "gotify" ? "green" : chName === "discord" ? "indigo" : "amber"
return (
<details key={chName} className="group">
<summary className={`flex items-center justify-between text-[11px] font-medium cursor-pointer hover:text-foreground transition-colors py-1.5 px-2 rounded-md hover:bg-muted/50 ${
hasOverrides ? `text-${chColor}-400` : "text-muted-foreground"
}`}>
<div className="flex items-center gap-2">
<ChevronDown className="h-3 w-3 group-open:rotate-180 transition-transform" />
<span>{chLabel}</span>
{hasOverrides && (
<span className={`text-[9px] px-1.5 py-0.5 rounded-full bg-${chColor}-500/15 text-${chColor}-400`}>
customized
</span>
)}
</div>
{!hasOverrides && (
<span className="text-[9px] text-muted-foreground/60">inherits global</span>
)}
</summary>
<div className="mt-1.5 ml-5 space-y-1">
{EVENT_CATEGORIES.map(cat => {
const globalEnabled = config.event_categories[cat.key] ?? true
const override = overrides.categories[cat.key]
const isCustomized = override !== undefined
const effectiveEnabled = isCustomized ? override : globalEnabled
return (
<div key={cat.key} className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/30">
<div className="flex items-center gap-2">
<span className={`text-[11px] ${effectiveEnabled ? "text-foreground" : "text-muted-foreground/50"}`}>
{cat.label}
</span>
{!isCustomized && (
<span className="text-[9px] text-muted-foreground/40">global</span>
)}
</div>
<div className="flex items-center gap-1.5">
{isCustomized && (
<button
type="button"
className="text-[9px] text-muted-foreground hover:text-foreground px-1"
disabled={!editMode}
onClick={() => {
if (!editMode) return
updateConfig(p => {
const ch = { ...(p.channel_overrides?.[chName] || { categories: {}, events: {} }) }
const cats = { ...ch.categories }
delete cats[cat.key]
return { ...p, channel_overrides: { ...p.channel_overrides, [chName]: { ...ch, categories: cats } } }
})
}}
>
reset
</button>
)}
<button
type="button"
role="switch"
aria-checked={effectiveEnabled}
disabled={!editMode}
className={`relative inline-flex h-3.5 w-6 shrink-0 items-center rounded-full transition-colors ${
!editMode ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
} ${effectiveEnabled ? `bg-${chColor}-600` : "bg-muted-foreground/30"}`}
onClick={() => {
if (!editMode) return
updateConfig(p => {
const ch = { ...(p.channel_overrides?.[chName] || { categories: {}, events: {} }) }
return {
...p,
channel_overrides: {
...p.channel_overrides,
[chName]: { ...ch, categories: { ...ch.categories, [cat.key]: !effectiveEnabled } }
}
}
})
}}
>
<span className={`pointer-events-none block h-2.5 w-2.5 rounded-full bg-background shadow-sm transition-transform ${
effectiveEnabled ? "translate-x-3" : "translate-x-0.5"
}`} />
</button>
</div>
</div>
)
})}
</div>
</details>
)
})}
{CHANNEL_TYPES.every(ch => !config.channels[ch]?.enabled) && (
<p className="text-[10px] text-muted-foreground/50 italic py-2">
Enable at least one channel above to configure per-channel filters.
</p>
)}
</div>
</div>
</div>{/* close bordered filters container */}
</div>
{/* ── Proxmox Webhook ── */}
<div className="space-y-3 border-t border-border pt-4">
<div className="flex items-center gap-2 mb-2">

View File

@@ -560,28 +560,14 @@ class NotificationManager:
print(f"[NotificationManager] Aggregation flush error: {e}")
def _process_event(self, event: NotificationEvent):
"""Process a single event: filter -> aggregate -> cooldown -> rate limit -> dispatch.
"""Process a single event: aggregate -> cooldown -> rate limit -> dispatch.
NOTE: Group and per-event filters are checked globally here.
Per-channel overrides are applied later in _dispatch_to_channels().
Per-channel category/event filters are applied in _dispatch_to_channels().
No global category/event filter exists -- each channel decides independently.
"""
if not self._enabled:
return
# Check if this event's GROUP is enabled globally.
template = TEMPLATES.get(event.event_type, {})
event_group = template.get('group', 'other')
group_setting = f'events.{event_group}'
if self._config.get(group_setting, 'true') == 'false':
return
# Check if this SPECIFIC event type is enabled globally.
# Default comes from the template's default_enabled field.
default_enabled = 'true' if template.get('default_enabled', True) else 'false'
event_specific = f'event.{event.event_type}'
if self._config.get(event_specific, default_enabled) == 'false':
return
# Try aggregation (may buffer the event)
result = self._aggregator.ingest(event)
if result is None:
@@ -592,23 +578,10 @@ class NotificationManager:
self._dispatch_event(event)
def _process_event_direct(self, event: NotificationEvent):
"""Process a burst summary event. Bypasses aggregator but applies global filters."""
"""Process a burst summary event. Bypasses aggregator but applies cooldown + rate limit."""
if not self._enabled:
return
# Check group filter
template = TEMPLATES.get(event.event_type, {})
event_group = template.get('group', 'other')
group_setting = f'events.{event_group}'
if self._config.get(group_setting, 'true') == 'false':
return
# Check per-event filter
default_enabled = 'true' if template.get('default_enabled', True) else 'false'
event_specific = f'event.{event.event_type}'
if self._config.get(event_specific, default_enabled) == 'false':
return
self._dispatch_event(event)
def _dispatch_event(self, event: NotificationEvent):
@@ -666,32 +639,32 @@ class NotificationManager:
def _dispatch_to_channels(self, title: str, body: str, severity: str,
event_type: str, data: Dict, source: str):
"""Send notification through configured channels, respecting per-channel overrides.
"""Send notification through configured channels, respecting per-channel filters.
Each channel can override global category/event settings:
- {channel}.events.{group} = "true"/"false" (category override)
- {channel}.event.{type} = "true"/"false" (per-event override)
If no override exists, the channel inherits the global setting (already checked).
Each channel owns its own category/event preferences:
- {channel}.events.{group} = "true"/"false" (category toggle, default "true")
- {channel}.event.{type} = "true"/"false" (per-event toggle, default from template)
No global fallback -- each channel decides independently what it receives.
"""
with self._lock:
channels = dict(self._channels)
template = TEMPLATES.get(event_type, {})
event_group = template.get('group', 'other')
default_event_enabled = 'true' if template.get('default_enabled', True) else 'false'
for ch_name, channel in channels.items():
# ── Per-channel override check ──
# If the channel has an explicit override for this group or event, respect it.
# If no override, the global filter already passed (checked in _process_event).
# ── Per-channel category check ──
# Default: category enabled (true) unless explicitly disabled.
ch_group_key = f'{ch_name}.events.{event_group}'
ch_group_override = self._config.get(ch_group_key)
if ch_group_override == 'false':
continue # Channel explicitly disabled this category
if self._config.get(ch_group_key, 'true') == 'false':
continue # Channel has this category disabled
# ── Per-channel event check ──
# Default: from template default_enabled, unless explicitly set.
ch_event_key = f'{ch_name}.event.{event_type}'
ch_event_override = self._config.get(ch_event_key)
if ch_event_override == 'false':
continue # Channel explicitly disabled this event
if self._config.get(ch_event_key, default_event_enabled) == 'false':
continue # Channel has this specific event disabled
try:
result = channel.send(title, body, severity, data)
@@ -1178,45 +1151,30 @@ class NotificationManager:
ch_cfg[config_key] = self._config.get(f'{ch_type}.{config_key}', '')
channels[ch_type] = ch_cfg
# Build event_categories dict (group-level toggle)
# EVENT_GROUPS is a dict: { 'vm_ct': {...}, 'services': {...}, 'health': {...}, ... }
event_categories = {}
for group_key in EVENT_GROUPS:
event_categories[group_key] = self._config.get(f'events.{group_key}', 'true') == 'true'
# Build per-event toggles: { 'vm_start': true, 'vm_stop': false, ... }
event_toggles = {}
for event_type, tmpl in TEMPLATES.items():
default = tmpl.get('default_enabled', True)
saved = self._config.get(f'event.{event_type}', None)
if saved is not None:
event_toggles[event_type] = saved == 'true'
else:
event_toggles[event_type] = default
# Build event_types_by_group for UI rendering
event_types_by_group = get_event_types_by_group()
# Build per-channel overrides
# Each channel independently owns its category and event toggles.
# Keys: {channel}.events.{group} and {channel}.event.{event_type}
# Defaults: categories default to true, events default to template default_enabled.
channel_overrides = {}
for ch_type in CHANNEL_TYPES:
ch_overrides = {'categories': {}, 'events': {}}
for group_key in EVENT_GROUPS:
val = self._config.get(f'{ch_type}.events.{group_key}')
if val is not None:
ch_overrides['categories'][group_key] = val == 'true'
for event_type_key in TEMPLATES:
val = self._config.get(f'{ch_type}.event.{event_type_key}')
if val is not None:
ch_overrides['events'][event_type_key] = val == 'true'
saved = self._config.get(f'{ch_type}.events.{group_key}')
ch_overrides['categories'][group_key] = (saved or 'true') == 'true'
for event_type_key, tmpl in TEMPLATES.items():
default = 'true' if tmpl.get('default_enabled', True) else 'false'
saved = self._config.get(f'{ch_type}.event.{event_type_key}')
ch_overrides['events'][event_type_key] = (saved or default) == 'true'
channel_overrides[ch_type] = ch_overrides
config = {
'enabled': self._enabled,
'channels': channels,
'event_categories': event_categories,
'event_toggles': event_toggles,
'event_categories': {},
'event_toggles': {},
'event_types_by_group': event_types_by_group,
'channel_overrides': channel_overrides,
'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',