feat(dashboard): v0.5.6 -- Advanced Rules editor polish (grouped categories, region_scope, name tooltip, smart activity badge)

Frontend-only polish to the Advanced Rules section in Notifications.tsx. Master Toggles (v0.5.0) and safe-mode are untouched.

FIX 1 -- Grouped Alert Categories. Replace the flat ~45-item checkbox list with a per-family grouped picker. Each family (mesh_health/weather/fire/rf_propagation/roads/avalanche/seismic/tracking) is a collapsible section with the lucide icon used by Master Toggles, a category count in the header, and per-family "All" / "Clear" bulk-toggle buttons. Families that already have selections expand by default. Categories whose toggle field does not match a known family fall into an "Other" group at the bottom. Uses the backend toggle field already provided by /api/notifications/categories.

FIX 2 -- region_scope multi-select. Adds a REGIONS block between WHEN and SEND VIA, wired to rule.region_scope (NotificationRuleConfig backend field has existed since v0.5.0). Fetches /api/regions alongside config/categories. Pill-style toggle buttons; empty selection means all regions (backward compat). region_scope added to createDefaultRule; addFromTemplate merges over createDefaultRule so future config fields do not need to be backfilled into every literal template.

FIX 3 -- truncated rule name hover tooltip. Adds title={rule.name} to the collapsed-header name span so long rule names stay readable on hover.

FIX 4 -- Smart activity badge. Replaces the unconditional "Never fired" badge with state-aware variants: gray "Disabled" when rule.enabled is false; green "Active" when fire_count > 0 and last_fired within 7 days; yellow "Idle (no recent activity)" when fire_count > 0 but last_fired > 7d ago; gray "No activity yet" when never fired (without implying breakage). Same badge Tailwind shape as before; last_fired surfaces via the title tooltip.

Backend untouched. npm run build (tsc strict) clean. PYTHONPATH=. pytest -q: 328 passed (unchanged from v0.5.5). Safe-mode preserved (master off, all toggles off, all adapters native, central disabled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-06-04 03:08:15 +00:00
commit d49e417400
4 changed files with 360 additions and 164 deletions

View file

@ -36,6 +36,7 @@ interface NotificationRuleConfig {
webhook_headers: Record<string, string>
cooldown_minutes: number
override_quiet: boolean
region_scope: string[]
}
interface NotificationToggle {
@ -73,6 +74,12 @@ interface AlertCategory {
description: string
default_severity: string
example_message: string
toggle?: string
}
interface RegionInfo {
name: string
local_name?: string
}
interface RuleStats {
@ -719,6 +726,7 @@ function NotificationRuleCard({
rule,
ruleIndex,
categories,
regions,
quietHoursEnabled,
onChange,
onDelete,
@ -728,6 +736,7 @@ function NotificationRuleCard({
rule: NotificationRuleConfig
ruleIndex: number
categories: AlertCategory[]
regions: RegionInfo[]
quietHoursEnabled: boolean
onChange: (r: NotificationRuleConfig) => void
onDelete: () => void
@ -793,6 +802,26 @@ function NotificationRuleCard({
}
}
const selectManyCategories = (catIds: string[], action: 'add' | 'remove') => {
const current = rule.categories || []
if (action === 'add') {
const merged = Array.from(new Set([...current, ...catIds]))
onChange({ ...rule, categories: merged })
} else {
const drop = new Set(catIds)
onChange({ ...rule, categories: current.filter(c => !drop.has(c)) })
}
}
const toggleRegion = (regionName: string) => {
const current = rule.region_scope || []
if (current.includes(regionName)) {
onChange({ ...rule, region_scope: current.filter(r => r !== regionName) })
} else {
onChange({ ...rule, region_scope: [...current, regionName] })
}
}
const toggleDay = (day: string) => {
const current = rule.schedule_days || []
if (current.includes(day)) {
@ -914,7 +943,7 @@ function NotificationRuleCard({
) : (
<Zap size={14} className="text-yellow-400 flex-shrink-0" />
)}
<span className="font-medium text-slate-200 truncate">{rule.name || 'New Rule'}</span>
<span className="font-medium text-slate-200 truncate" title={rule.name || undefined}>{rule.name || 'New Rule'}</span>
{!expanded && (
<span className={`text-xs truncate hidden sm:block ${!rule.delivery_type ? 'text-amber-400' : 'text-slate-500'}`}>
{getSummary()}
@ -922,12 +951,32 @@ function NotificationRuleCard({
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Stats badge */}
{ruleStats && !expanded && (
<span className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 bg-slate-800 rounded text-xs text-slate-400 mr-2">
{ruleStats.last_fired ? formatRelativeTime(ruleStats.last_fired) : 'Never fired'}
</span>
)}
{/* Activity badge */}
{!expanded && (() => {
const badgeBase = 'hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs mr-2'
if (!rule.enabled) {
return <span className={`${badgeBase} bg-slate-800 text-slate-500`}>Disabled</span>
}
if (!ruleStats) return null
const count = ruleStats.fire_count || 0
const last = ruleStats.last_fired
const sevenDaysAgo = (Date.now() / 1000) - 7 * 86400
if (count > 0 && last && last >= sevenDaysAgo) {
return (
<span className={`${badgeBase} bg-green-500/10 text-green-400`} title={`Last fired ${formatRelativeTime(last)}`}>
Active
</span>
)
}
if (count > 0 && last) {
return (
<span className={`${badgeBase} bg-yellow-500/10 text-yellow-400`} title={`Last fired ${formatRelativeTime(last)}`}>
Idle (no recent activity)
</span>
)
}
return <span className={`${badgeBase} bg-slate-800 text-slate-400`}>No activity yet</span>
})()}
{/* Source indicators */}
{!expanded && (
<div className="hidden md:flex items-center gap-1 mr-2">
@ -1040,30 +1089,17 @@ function NotificationRuleCard({
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Alert Categories
<InfoButton info="Select which types of alerts trigger this rule. Leave all unchecked to match ALL categories." />
<InfoButton info="Select which types of alerts trigger this rule. Leave all unchecked to match ALL categories. Categories are grouped by family — use the 'All' / 'Clear' buttons in each header to bulk-toggle." />
</label>
<div className="text-xs text-slate-500 mb-2">
{(rule.categories?.length || 0) === 0 ? 'All categories (none selected)' : `${rule.categories?.length} selected`}
</div>
<div className="max-h-48 overflow-y-auto border border-[#1e2a3a] rounded-lg p-2 space-y-1">
{categories.map((cat) => (
<label
key={cat.id}
onClick={() => toggleCategory(cat.id)}
className="flex items-start gap-2 p-2 rounded hover:bg-[#1e2a3a]/50 cursor-pointer"
>
<div className={`w-4 h-4 mt-0.5 rounded border flex items-center justify-center flex-shrink-0 ${
rule.categories?.includes(cat.id) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{rule.categories?.includes(cat.id) && <Check size={12} className="text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200">{cat.name}</div>
<div className="text-xs text-slate-500">{cat.description}</div>
</div>
</label>
))}
</div>
<GroupedCategoryPicker
categories={categories}
selected={rule.categories || []}
onToggle={toggleCategory}
onSelectMany={selectManyCategories}
/>
</div>
{/* Source health display */}
@ -1170,6 +1206,44 @@ function NotificationRuleCard({
</div>
)}
{/* REGIONS section — scope rule to specific regions; empty = all regions */}
<div className="space-y-2 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<MapPin size={14} />
REGIONS
<InfoButton info="Limit this rule to alerts from specific regions. Empty selection = all regions (backward compatible). Region names come from /api/regions." />
</div>
<div className="text-xs text-slate-500">
{(rule.region_scope?.length || 0) === 0
? 'All regions (none selected)'
: `${rule.region_scope.length} of ${regions.length} selected`}
</div>
{regions.length === 0 ? (
<div className="text-xs text-slate-600 italic">No regions configured.</div>
) : (
<div className="flex flex-wrap gap-2">
{regions.map(r => {
const on = (rule.region_scope || []).includes(r.name)
return (
<button
key={r.name}
type="button"
onClick={() => toggleRegion(r.name)}
className={`px-3 py-1.5 rounded text-sm transition-colors ${
on
? 'bg-accent text-white'
: 'bg-[#1e2a3a] text-slate-400 hover:text-slate-200'
}`}
title={r.local_name || r.name}
>
{r.local_name || r.name}
</button>
)
})}
</div>
)}
</div>
{/* SEND VIA section */}
<div className="space-y-4 p-4 bg-[#0a0e17] rounded-lg border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
@ -1372,6 +1446,119 @@ const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[
{ key: 'seismic', label: 'Seismic', Icon: Mountain },
{ key: 'tracking', label: 'Tracking', Icon: MapPin },
]
// Grouped category picker — shows categories family-by-family using TOGGLE_FAMILY_META
// for icons/labels/order. Each family has a Select-all / Clear control and a count.
// Categories whose `toggle` field doesn't match a known family fall into "Other".
function GroupedCategoryPicker({
categories,
selected,
onToggle,
onSelectMany,
}: {
categories: AlertCategory[]
selected: string[]
onToggle: (catId: string) => void
onSelectMany: (catIds: string[], action: 'add' | 'remove') => void
}) {
const FAMILY_KEYS = new Set(TOGGLE_FAMILY_META.map(f => f.key))
// Group by toggle, preserving family order; collect unknowns into "other".
const byFamily = new Map<string, AlertCategory[]>()
TOGGLE_FAMILY_META.forEach(f => byFamily.set(f.key, []))
const other: AlertCategory[] = []
for (const cat of categories) {
const fam = cat.toggle
if (fam && FAMILY_KEYS.has(fam)) byFamily.get(fam)!.push(cat)
else other.push(cat)
}
// Track which families are expanded. Default: any family that has selected items
// starts expanded so users can see what's checked.
const initialOpen = new Set<string>()
for (const [fam, cats] of byFamily) {
if (cats.some(c => selected.includes(c.id))) initialOpen.add(fam)
}
if (other.some(c => selected.includes(c.id))) initialOpen.add('other')
const [openFamilies, setOpenFamilies] = useState<Set<string>>(initialOpen)
const toggleOpen = (key: string) => {
setOpenFamilies(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key); else next.add(key)
return next
})
}
const renderGroup = (key: string, label: string, Icon: typeof Activity | null, cats: AlertCategory[]) => {
if (!cats.length) return null
const isOpen = openFamilies.has(key)
const ids = cats.map(c => c.id)
const selectedInFamily = ids.filter(id => selected.includes(id)).length
return (
<div key={key} className="border border-[#1e2a3a] rounded">
<div className="flex items-center justify-between px-2 py-1.5 bg-[#0d1420]">
<button
type="button"
onClick={() => toggleOpen(key)}
className="flex items-center gap-2 text-sm text-slate-200 flex-1 min-w-0"
>
{isOpen ? <ChevronDown size={14} className="text-slate-500 flex-shrink-0" /> : <ChevronRight size={14} className="text-slate-500 flex-shrink-0" />}
{Icon && <Icon size={14} className="text-slate-400 flex-shrink-0" />}
<span className="truncate">{label} ({cats.length})</span>
{selectedInFamily > 0 && (
<span className="ml-1 text-xs text-accent">{selectedInFamily} selected</span>
)}
</button>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectMany(ids, 'add') }}
className="text-xs px-2 py-0.5 rounded text-slate-400 hover:text-accent hover:bg-accent/10"
title="Select all in family"
>
All
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectMany(ids, 'remove') }}
className="text-xs px-2 py-0.5 rounded text-slate-400 hover:text-red-400 hover:bg-red-500/10"
title="Clear family"
>
Clear
</button>
</div>
</div>
{isOpen && (
<div className="p-1 space-y-1">
{cats.map(cat => (
<label
key={cat.id}
onClick={() => onToggle(cat.id)}
className="flex items-start gap-2 p-2 rounded hover:bg-[#1e2a3a]/50 cursor-pointer"
>
<div className={`w-4 h-4 mt-0.5 rounded border flex items-center justify-center flex-shrink-0 ${
selected.includes(cat.id) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{selected.includes(cat.id) && <Check size={12} className="text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200">{cat.name}</div>
<div className="text-xs text-slate-500">{cat.description}</div>
</div>
</label>
))}
</div>
)}
</div>
)
}
return (
<div className="max-h-96 overflow-y-auto border border-[#1e2a3a] rounded-lg p-2 space-y-2">
{TOGGLE_FAMILY_META.map(f => renderGroup(f.key, f.label, f.Icon, byFamily.get(f.key) || []))}
{renderGroup('other', 'Other', null, other)}
</div>
)
}
const TOGGLE_CHANNELS = ['digest', 'mesh_broadcast', 'mesh_dm', 'email', 'webhook']
const TOGGLE_SEVERITIES = ['routine', 'priority', 'immediate']
@ -1465,6 +1652,7 @@ export default function Notifications() {
const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
const [categories, setCategories] = useState<AlertCategory[]>([])
const [regions, setRegions] = useState<RegionInfo[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -1476,16 +1664,20 @@ export default function Notifications() {
const fetchConfig = useCallback(async () => {
try {
const [configRes, categoriesRes] = await Promise.all([
const [configRes, categoriesRes, regionsRes] = await Promise.all([
fetch('/api/config/notifications'),
fetch('/api/notifications/categories'),
fetch('/api/regions'),
])
if (!configRes.ok) throw new Error('Failed to fetch notifications config')
const configData = await configRes.json()
const categoriesData = await categoriesRes.json()
// /api/regions is best-effort — don't block the page if it fails.
const regionsData: RegionInfo[] = regionsRes.ok ? await regionsRes.json() : []
setConfig(configData)
setOriginalConfig(JSON.parse(JSON.stringify(configData)))
setCategories(categoriesData)
setRegions(Array.isArray(regionsData) ? regionsData : [])
setHasChanges(false)
setError(null)
} catch (err) {
@ -1570,6 +1762,7 @@ export default function Notifications() {
webhook_headers: {},
cooldown_minutes: 10,
override_quiet: false,
region_scope: [],
})
const addRule = () => {
@ -1581,7 +1774,9 @@ export default function Notifications() {
if (!config) return
const template = RULE_TEMPLATES.find(t => t.id === templateId)
if (!template) return
setConfig({ ...config, rules: [...(config.rules || []), { ...template.rule }] })
// Merge over defaults so future NotificationRuleConfig fields (e.g., region_scope)
// don't have to be back-filled into every literal template.
setConfig({ ...config, rules: [...(config.rules || []), { ...createDefaultRule(), ...template.rule }] })
setShowTemplates(false)
}
@ -1949,6 +2144,7 @@ export default function Notifications() {
rule={rule}
ruleIndex={i}
categories={categories}
regions={regions}
quietHoursEnabled={config.quiet_hours_enabled ?? true}
onChange={(r) => {
const newRules = [...(config.rules || [])]

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-B24tHcYj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DjhQa8Mv.css">
<script type="module" crossorigin src="/assets/index-DwsA2DLM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHkr5tDL.css">
</head>
<body>
<div id="root"></div>