mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-04-29 10:56:26 +00:00
update notification service
This commit is contained in:
@@ -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. */
|
/** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */
|
||||||
const flattenConfig = (cfg: NotificationConfig): Record<string, string> => {
|
const flattenConfig = (cfg: NotificationConfig): Record<string, string> => {
|
||||||
const flat: Record<string, string> = {
|
const flat: Record<string, string> = {
|
||||||
@@ -244,28 +386,8 @@ export function NotificationSettings() {
|
|||||||
flat[`${chName}.${field}`] = String(value ?? "")
|
flat[`${chName}.${field}`] = String(value ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flatten global event_categories: { vm_ct: true, backup: false } -> events.vm_ct, events.backup
|
// Per-channel category & event toggles: telegram.events.vm_ct, telegram.event.vm_start, etc.
|
||||||
for (const [cat, enabled] of Object.entries(cfg.event_categories)) {
|
// Each channel independently owns its notification preferences.
|
||||||
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.
|
|
||||||
if (cfg.channel_overrides) {
|
if (cfg.channel_overrides) {
|
||||||
for (const [chName, overrides] of Object.entries(cfg.channel_overrides)) {
|
for (const [chName, overrides] of Object.entries(cfg.channel_overrides)) {
|
||||||
if (overrides.categories) {
|
if (overrides.categories) {
|
||||||
@@ -754,6 +876,7 @@ matcher: proxmenux-pbs
|
|||||||
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{renderChannelCategories("telegram", "blue")}
|
||||||
{/* Per-channel action bar */}
|
{/* Per-channel action bar */}
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<button
|
<button
|
||||||
@@ -823,6 +946,7 @@ matcher: proxmenux-pbs
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderChannelCategories("gotify", "green")}
|
||||||
{/* Per-channel action bar */}
|
{/* Per-channel action bar */}
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<button
|
<button
|
||||||
@@ -883,6 +1007,7 @@ matcher: proxmenux-pbs
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{renderChannelCategories("discord", "indigo")}
|
||||||
{/* Per-channel action bar */}
|
{/* Per-channel action bar */}
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<button
|
<button
|
||||||
@@ -1024,6 +1149,7 @@ matcher: proxmenux-pbs
|
|||||||
For Gmail, use an App Password instead of your account password.
|
For Gmail, use an App Password instead of your account password.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{renderChannelCategories("email", "amber")}
|
||||||
{/* Per-channel action bar */}
|
{/* Per-channel action bar */}
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
<div className="flex items-center gap-2 pt-2 border-t border-border/50">
|
||||||
<button
|
<button
|
||||||
@@ -1066,255 +1192,6 @@ matcher: proxmenux-pbs
|
|||||||
</div>{/* close bordered channel container */}
|
</div>{/* close bordered channel container */}
|
||||||
</div>
|
</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 ── */}
|
{/* ── Proxmox Webhook ── */}
|
||||||
<div className="space-y-3 border-t border-border pt-4">
|
<div className="space-y-3 border-t border-border pt-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
|||||||
@@ -560,28 +560,14 @@ class NotificationManager:
|
|||||||
print(f"[NotificationManager] Aggregation flush error: {e}")
|
print(f"[NotificationManager] Aggregation flush error: {e}")
|
||||||
|
|
||||||
def _process_event(self, event: NotificationEvent):
|
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 category/event filters are applied in _dispatch_to_channels().
|
||||||
Per-channel overrides are applied later in _dispatch_to_channels().
|
No global category/event filter exists -- each channel decides independently.
|
||||||
"""
|
"""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return
|
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)
|
# Try aggregation (may buffer the event)
|
||||||
result = self._aggregator.ingest(event)
|
result = self._aggregator.ingest(event)
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -592,23 +578,10 @@ class NotificationManager:
|
|||||||
self._dispatch_event(event)
|
self._dispatch_event(event)
|
||||||
|
|
||||||
def _process_event_direct(self, event: NotificationEvent):
|
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:
|
if not self._enabled:
|
||||||
return
|
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)
|
self._dispatch_event(event)
|
||||||
|
|
||||||
def _dispatch_event(self, event: NotificationEvent):
|
def _dispatch_event(self, event: NotificationEvent):
|
||||||
@@ -666,32 +639,32 @@ class NotificationManager:
|
|||||||
|
|
||||||
def _dispatch_to_channels(self, title: str, body: str, severity: str,
|
def _dispatch_to_channels(self, title: str, body: str, severity: str,
|
||||||
event_type: str, data: Dict, source: 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:
|
Each channel owns its own category/event preferences:
|
||||||
- {channel}.events.{group} = "true"/"false" (category override)
|
- {channel}.events.{group} = "true"/"false" (category toggle, default "true")
|
||||||
- {channel}.event.{type} = "true"/"false" (per-event override)
|
- {channel}.event.{type} = "true"/"false" (per-event toggle, default from template)
|
||||||
If no override exists, the channel inherits the global setting (already checked).
|
No global fallback -- each channel decides independently what it receives.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
channels = dict(self._channels)
|
channels = dict(self._channels)
|
||||||
|
|
||||||
template = TEMPLATES.get(event_type, {})
|
template = TEMPLATES.get(event_type, {})
|
||||||
event_group = template.get('group', 'other')
|
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():
|
for ch_name, channel in channels.items():
|
||||||
# ── Per-channel override check ──
|
# ── Per-channel category check ──
|
||||||
# If the channel has an explicit override for this group or event, respect it.
|
# Default: category enabled (true) unless explicitly disabled.
|
||||||
# If no override, the global filter already passed (checked in _process_event).
|
|
||||||
ch_group_key = f'{ch_name}.events.{event_group}'
|
ch_group_key = f'{ch_name}.events.{event_group}'
|
||||||
ch_group_override = self._config.get(ch_group_key)
|
if self._config.get(ch_group_key, 'true') == 'false':
|
||||||
if ch_group_override == 'false':
|
continue # Channel has this category disabled
|
||||||
continue # Channel explicitly disabled this category
|
|
||||||
|
|
||||||
|
# ── Per-channel event check ──
|
||||||
|
# Default: from template default_enabled, unless explicitly set.
|
||||||
ch_event_key = f'{ch_name}.event.{event_type}'
|
ch_event_key = f'{ch_name}.event.{event_type}'
|
||||||
ch_event_override = self._config.get(ch_event_key)
|
if self._config.get(ch_event_key, default_event_enabled) == 'false':
|
||||||
if ch_event_override == 'false':
|
continue # Channel has this specific event disabled
|
||||||
continue # Channel explicitly disabled this event
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = channel.send(title, body, severity, data)
|
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}', '')
|
ch_cfg[config_key] = self._config.get(f'{ch_type}.{config_key}', '')
|
||||||
channels[ch_type] = ch_cfg
|
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
|
# Build event_types_by_group for UI rendering
|
||||||
event_types_by_group = get_event_types_by_group()
|
event_types_by_group = get_event_types_by_group()
|
||||||
|
|
||||||
# Build per-channel overrides
|
# Build per-channel overrides
|
||||||
|
# Each channel independently owns its category and event toggles.
|
||||||
# Keys: {channel}.events.{group} and {channel}.event.{event_type}
|
# Keys: {channel}.events.{group} and {channel}.event.{event_type}
|
||||||
|
# Defaults: categories default to true, events default to template default_enabled.
|
||||||
channel_overrides = {}
|
channel_overrides = {}
|
||||||
for ch_type in CHANNEL_TYPES:
|
for ch_type in CHANNEL_TYPES:
|
||||||
ch_overrides = {'categories': {}, 'events': {}}
|
ch_overrides = {'categories': {}, 'events': {}}
|
||||||
for group_key in EVENT_GROUPS:
|
for group_key in EVENT_GROUPS:
|
||||||
val = self._config.get(f'{ch_type}.events.{group_key}')
|
saved = self._config.get(f'{ch_type}.events.{group_key}')
|
||||||
if val is not None:
|
ch_overrides['categories'][group_key] = (saved or 'true') == 'true'
|
||||||
ch_overrides['categories'][group_key] = val == 'true'
|
for event_type_key, tmpl in TEMPLATES.items():
|
||||||
for event_type_key in TEMPLATES:
|
default = 'true' if tmpl.get('default_enabled', True) else 'false'
|
||||||
val = self._config.get(f'{ch_type}.event.{event_type_key}')
|
saved = self._config.get(f'{ch_type}.event.{event_type_key}')
|
||||||
if val is not None:
|
ch_overrides['events'][event_type_key] = (saved or default) == 'true'
|
||||||
ch_overrides['events'][event_type_key] = val == 'true'
|
|
||||||
channel_overrides[ch_type] = ch_overrides
|
channel_overrides[ch_type] = ch_overrides
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
'enabled': self._enabled,
|
'enabled': self._enabled,
|
||||||
'channels': channels,
|
'channels': channels,
|
||||||
'event_categories': event_categories,
|
'event_categories': {},
|
||||||
'event_toggles': event_toggles,
|
'event_toggles': {},
|
||||||
'event_types_by_group': event_types_by_group,
|
'event_types_by_group': event_types_by_group,
|
||||||
'channel_overrides': channel_overrides,
|
'channel_overrides': channel_overrides,
|
||||||
'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',
|
'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',
|
||||||
|
|||||||
Reference in New Issue
Block a user