feat(15-03): add alert rules UI, alert events table, and notification bell

- AlertRuleFormDialog with rule type selector, threshold input, auto-set units
- AlertRulesTab with list, enable toggle, edit, delete, and add button
- AlertEventsTable with severity badges, resolve action, and state filter tabs
- NotificationBell polls active alert count with 60s interval
- Site dashboard gains Alerts tab rendering both AlertRulesTab and AlertEventsTable
- NotificationBell integrated into ContextStrip header for tenant users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 07:25:24 -05:00
parent 3bddd6f654
commit d1495ee90d
6 changed files with 578 additions and 3 deletions

View File

@@ -0,0 +1,148 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle2 } from 'lucide-react'
import { alertEventsApi, type SiteAlertEventResponse } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type FilterState = 'all' | 'active' | 'resolved'
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
interface AlertEventsTableProps {
tenantId: string
siteId: string
}
export function AlertEventsTable({ tenantId, siteId }: AlertEventsTableProps) {
const queryClient = useQueryClient()
const [filter, setFilter] = useState<FilterState>('all')
const stateParam = filter === 'all' ? undefined : filter
const { data, isLoading } = useQuery({
queryKey: ['alert-events', tenantId, siteId, stateParam],
queryFn: () => alertEventsApi.list(tenantId, siteId, stateParam),
})
const resolveMutation = useMutation({
mutationFn: (eventId: string) => alertEventsApi.resolve(tenantId, eventId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['alert-events', tenantId, siteId] })
void queryClient.invalidateQueries({ queryKey: ['alert-event-count'] })
},
})
const events = data?.items ?? []
return (
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-3 py-2 border-b border-border bg-elevated/30 flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">Alert Events</h3>
<div className="flex gap-1">
{(['all', 'active', 'resolved'] as FilterState[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-2 py-0.5 text-[10px] font-medium rounded transition-colors capitalize',
filter === f
? 'bg-accent text-white'
: 'bg-elevated text-text-muted hover:text-text-secondary',
)}
>
{f}
</button>
))}
</div>
</div>
{isLoading ? (
<div className="p-4 text-sm text-text-muted">Loading events...</div>
) : events.length === 0 ? (
<div className="p-6 text-center">
<p className="text-sm text-text-muted">
{filter === 'all' ? 'No alert events' : `No ${filter} alert events`}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">Severity</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">Message</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">Triggered</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-center">State</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">Actions</th>
</tr>
</thead>
<tbody>
{events.map((evt: SiteAlertEventResponse) => (
<tr key={evt.id} className="border-b border-border/50 hover:bg-elevated/50 transition-colors">
<td className="px-2 py-1.5">
<span
className={cn(
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
evt.severity === 'critical'
? 'bg-error/20 text-error border-error/40'
: 'bg-warning/20 text-warning border-warning/40',
)}
>
{evt.severity}
</span>
</td>
<td className="px-2 py-1.5 text-text-primary max-w-xs truncate">
{evt.message}
</td>
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
{timeAgo(evt.triggered_at)}
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex items-center justify-center gap-1.5">
<span
className={cn(
'w-2 h-2 rounded-full',
evt.state === 'active' ? 'bg-error' : 'bg-success',
)}
/>
<span className="text-xs text-text-secondary capitalize">{evt.state}</span>
</div>
</td>
<td className="px-2 py-1.5 text-right">
{evt.state === 'active' ? (
<Button
size="sm"
variant="ghost"
onClick={() => resolveMutation.mutate(evt.id)}
disabled={resolveMutation.isPending}
className="text-xs"
>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
Resolve
</Button>
) : (
<span className="text-xs text-text-muted">
{evt.resolved_at ? timeAgo(evt.resolved_at) : ''}
{evt.resolved_by ? ` by ${evt.resolved_by}` : ''}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,228 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
alertRulesApi,
type SiteAlertRuleResponse,
type SiteAlertRuleCreate,
type SiteAlertRuleUpdate,
} from '@/lib/api'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
const RULE_TYPES = {
device_offline_percent: { label: 'Device offline %', unit: '%' },
device_offline_count: { label: 'Device offline count', unit: 'devices' },
sector_signal_avg: { label: 'Sector avg signal', unit: 'dBm' },
sector_client_drop: { label: 'Sector client drop %', unit: '%' },
} as const
type RuleType = keyof typeof RULE_TYPES
interface AlertRuleFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
tenantId: string
siteId: string
sectorId?: string
rule?: SiteAlertRuleResponse | null
onSaved?: () => void
}
export function AlertRuleFormDialog({
open,
onOpenChange,
tenantId,
siteId,
sectorId,
rule,
onSaved,
}: AlertRuleFormDialogProps) {
const queryClient = useQueryClient()
const isEdit = !!rule
const [name, setName] = useState('')
const [ruleType, setRuleType] = useState<RuleType>('device_offline_percent')
const [thresholdValue, setThresholdValue] = useState('')
const [description, setDescription] = useState('')
const [enabled, setEnabled] = useState(true)
// Filter rule types based on whether we have a sector context
const availableTypes = sectorId
? (['sector_signal_avg', 'sector_client_drop'] as RuleType[])
: (['device_offline_percent', 'device_offline_count'] as RuleType[])
useEffect(() => {
if (rule) {
setName(rule.name)
setRuleType(rule.rule_type as RuleType)
setThresholdValue(String(rule.threshold_value))
setDescription(rule.description ?? '')
setEnabled(rule.enabled)
} else {
setName('')
setRuleType(availableTypes[0])
setThresholdValue('')
setDescription('')
setEnabled(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rule, open])
const unit = RULE_TYPES[ruleType]?.unit ?? ''
const createMutation = useMutation({
mutationFn: (data: SiteAlertRuleCreate) => alertRulesApi.create(tenantId, siteId, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['alert-rules', tenantId, siteId] })
onOpenChange(false)
onSaved?.()
},
})
const updateMutation = useMutation({
mutationFn: (data: SiteAlertRuleUpdate) => alertRulesApi.update(tenantId, siteId, rule!.id, data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['alert-rules', tenantId, siteId] })
onOpenChange(false)
onSaved?.()
},
})
const isPending = createMutation.isPending || updateMutation.isPending
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const threshold = parseFloat(thresholdValue)
if (isNaN(threshold)) return
if (isEdit) {
updateMutation.mutate({
name: name.trim(),
threshold_value: threshold,
threshold_unit: unit,
description: description.trim() || undefined,
enabled,
})
} else {
createMutation.mutate({
name: name.trim(),
rule_type: ruleType,
threshold_value: threshold,
threshold_unit: unit,
sector_id: sectorId,
description: description.trim() || undefined,
enabled,
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Alert Rule' : 'Add Alert Rule'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update alert rule settings.'
: 'Create a rule to trigger alerts when conditions are met.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="rule-name">Name *</Label>
<Input
id="rule-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Alert when offline > 20%"
required
/>
</div>
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="rule-type">Rule Type *</Label>
<Select value={ruleType} onValueChange={(v) => setRuleType(v as RuleType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypes.map((t) => (
<SelectItem key={t} value={t}>
{RULE_TYPES[t].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="rule-threshold">
Threshold ({unit}) *
</Label>
<Input
id="rule-threshold"
type="number"
step="any"
value={thresholdValue}
onChange={(e) => setThresholdValue(e.target.value)}
placeholder={unit === 'dBm' ? '-75' : '20'}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rule-description">Description</Label>
<textarea
id="rule-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional details about this rule..."
rows={2}
className="flex w-full rounded-md border border-border bg-elevated/50 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted transition-colors focus:border-accent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 resize-none"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="rule-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<Label htmlFor="rule-enabled" className="text-sm font-normal">
Enabled
</Label>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim() || !thresholdValue || isPending}>
{isEdit ? 'Save Changes' : 'Create Rule'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { alertRulesApi, type SiteAlertRuleResponse } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { AlertRuleFormDialog } from './AlertRuleFormDialog'
const RULE_TYPE_LABELS: Record<string, string> = {
device_offline_percent: 'Offline %',
device_offline_count: 'Offline count',
sector_signal_avg: 'Avg signal',
sector_client_drop: 'Client drop %',
}
interface AlertRulesTabProps {
tenantId: string
siteId: string
sectorId?: string
}
export function AlertRulesTab({ tenantId, siteId, sectorId }: AlertRulesTabProps) {
const queryClient = useQueryClient()
const [dialogOpen, setDialogOpen] = useState(false)
const [editingRule, setEditingRule] = useState<SiteAlertRuleResponse | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['alert-rules', tenantId, siteId, sectorId],
queryFn: () => alertRulesApi.list(tenantId, siteId, sectorId),
})
const toggleMutation = useMutation({
mutationFn: ({ ruleId, enabled }: { ruleId: string; enabled: boolean }) =>
alertRulesApi.update(tenantId, siteId, ruleId, { enabled }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['alert-rules', tenantId, siteId] })
},
})
const deleteMutation = useMutation({
mutationFn: (ruleId: string) => alertRulesApi.delete(tenantId, siteId, ruleId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['alert-rules', tenantId, siteId] })
},
})
function handleEdit(rule: SiteAlertRuleResponse) {
setEditingRule(rule)
setDialogOpen(true)
}
function handleDelete(ruleId: string) {
if (window.confirm('Delete this alert rule?')) {
deleteMutation.mutate(ruleId)
}
}
function handleAdd() {
setEditingRule(null)
setDialogOpen(true)
}
const rules = data?.items ?? []
return (
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-3 py-2 border-b border-border bg-elevated/30 flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">Alert Rules</h3>
<Button size="sm" variant="ghost" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Rule
</Button>
</div>
{isLoading ? (
<div className="p-4 text-sm text-text-muted">Loading rules...</div>
) : rules.length === 0 ? (
<div className="p-6 text-center">
<p className="text-sm text-text-muted mb-3">No alert rules configured</p>
<Button size="sm" onClick={handleAdd}>
<Plus className="h-3.5 w-3.5 mr-1" /> Add Alert Rule
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">Name</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">Type</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">Threshold</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-center">Enabled</th>
<th className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">Actions</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id} className="border-b border-border/50 hover:bg-elevated/50 transition-colors">
<td className="px-2 py-1.5 text-text-primary">{rule.name}</td>
<td className="px-2 py-1.5 text-text-secondary text-xs">
{RULE_TYPE_LABELS[rule.rule_type] ?? rule.rule_type}
</td>
<td className="px-2 py-1.5 text-right font-mono text-text-secondary">
{rule.threshold_value} {rule.threshold_unit}
</td>
<td className="px-2 py-1.5 text-center">
<button
onClick={() => toggleMutation.mutate({ ruleId: rule.id, enabled: !rule.enabled })}
className={`w-8 h-4 rounded-full relative transition-colors ${
rule.enabled ? 'bg-success' : 'bg-elevated'
}`}
aria-label={rule.enabled ? 'Disable rule' : 'Enable rule'}
>
<span
className={`block w-3 h-3 rounded-full bg-white transition-transform absolute top-0.5 ${
rule.enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</td>
<td className="px-2 py-1.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => handleEdit(rule)}
className="p-1 rounded text-text-muted hover:text-text-primary transition-colors"
aria-label="Edit rule"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(rule.id)}
className="p-1 rounded text-text-muted hover:text-error transition-colors"
aria-label="Delete rule"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AlertRuleFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
tenantId={tenantId}
siteId={siteId}
sectorId={sectorId}
rule={editingRule}
/>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query'
import { Bell } from 'lucide-react'
import { alertEventsApi } from '@/lib/api'
interface NotificationBellProps {
tenantId: string
}
export function NotificationBell({ tenantId }: NotificationBellProps) {
const { data: count } = useQuery({
queryKey: ['alert-event-count', tenantId],
queryFn: () => alertEventsApi.activeCount(tenantId),
refetchInterval: 60_000,
enabled: !!tenantId,
})
const activeCount = count ?? 0
return (
<button
className="relative p-1 rounded text-text-muted hover:text-text-primary transition-colors"
aria-label={`${activeCount} active alert${activeCount !== 1 ? 's' : ''}`}
title={`${activeCount} active alert${activeCount !== 1 ? 's' : ''}`}
>
<Bell className="h-3.5 w-3.5" />
{activeCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[14px] h-[14px] flex items-center justify-center rounded-full bg-error text-white text-[8px] font-bold px-0.5 leading-none">
{activeCount > 99 ? '99+' : activeCount}
</span>
)}
</button>
)
}

View File

@@ -16,6 +16,7 @@ import { useUIStore } from '@/lib/store'
import { tenantsApi, metricsApi } from '@/lib/api'
import { useEventStreamContext } from '@/contexts/EventStreamContext'
import type { ConnectionState } from '@/hooks/useEventStream'
import { NotificationBell } from '@/components/alerts/NotificationBell'
const SYSTEM_TENANT_ID = '00000000-0000-0000-0000-000000000000'
@@ -191,6 +192,9 @@ export function ContextStrip() {
{/* Right: Actions */}
<div className="flex items-center gap-2 ml-auto">
{/* Notification bell */}
{tenantId && <NotificationBell tenantId={tenantId} />}
{/* Command palette shortcut */}
<button
onClick={() => useCommandPalette.getState().setOpen(true)}

View File

@@ -8,6 +8,8 @@ import { cn } from '@/lib/utils'
import { SiteHealthGrid } from '@/components/sites/SiteHealthGrid'
import { SiteSectorView } from '@/components/sites/SiteSectorView'
import { SiteLinksTab } from '@/components/sites/SiteLinksTab'
import { AlertRulesTab } from '@/components/alerts/AlertRulesTab'
import { AlertEventsTable } from '@/components/alerts/AlertEventsTable'
export const Route = createFileRoute('/_authenticated/tenants/$tenantId/sites/$siteId')({
component: SiteDetailPage,
@@ -15,7 +17,7 @@ export const Route = createFileRoute('/_authenticated/tenants/$tenantId/sites/$s
function SiteDetailPage() {
const { tenantId, siteId } = Route.useParams()
const [activeTab, setActiveTab] = useState<'health' | 'sectors' | 'links'>('health')
const [activeTab, setActiveTab] = useState<'health' | 'sectors' | 'links' | 'alerts'>('health')
const { data: site, isLoading } = useQuery({
queryKey: ['sites', tenantId, siteId],
@@ -118,7 +120,7 @@ function SiteDetailPage() {
{/* Tab bar */}
<div className="flex gap-1 border-b border-border">
{(['health', 'sectors', 'links'] as const).map((tab) => (
{(['health', 'sectors', 'links', 'alerts'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -129,7 +131,7 @@ function SiteDetailPage() {
: 'border-transparent text-text-muted hover:text-text-secondary',
)}
>
{tab === 'health' ? 'Health Grid' : tab === 'sectors' ? 'Sectors' : 'Links'}
{tab === 'health' ? 'Health Grid' : tab === 'sectors' ? 'Sectors' : tab === 'links' ? 'Links' : 'Alerts'}
</button>
))}
</div>
@@ -138,6 +140,12 @@ function SiteDetailPage() {
{activeTab === 'health' && <SiteHealthGrid tenantId={tenantId} siteId={siteId} />}
{activeTab === 'sectors' && <SiteSectorView tenantId={tenantId} siteId={siteId} />}
{activeTab === 'links' && <SiteLinksTab tenantId={tenantId} siteId={siteId} />}
{activeTab === 'alerts' && (
<div className="space-y-6">
<AlertRulesTab tenantId={tenantId} siteId={siteId} />
<AlertEventsTable tenantId={tenantId} siteId={siteId} />
</div>
)}
</div>
)
}