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:
148
frontend/src/components/alerts/AlertEventsTable.tsx
Normal file
148
frontend/src/components/alerts/AlertEventsTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
frontend/src/components/alerts/AlertRuleFormDialog.tsx
Normal file
228
frontend/src/components/alerts/AlertRuleFormDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
frontend/src/components/alerts/AlertRulesTab.tsx
Normal file
154
frontend/src/components/alerts/AlertRulesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/alerts/NotificationBell.tsx
Normal file
33
frontend/src/components/alerts/NotificationBell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user