diff --git a/frontend/src/components/alerts/AlertEventsTable.tsx b/frontend/src/components/alerts/AlertEventsTable.tsx new file mode 100644 index 0000000..d25f159 --- /dev/null +++ b/frontend/src/components/alerts/AlertEventsTable.tsx @@ -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('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 ( +
+
+

Alert Events

+
+ {(['all', 'active', 'resolved'] as FilterState[]).map((f) => ( + + ))} +
+
+ + {isLoading ? ( +
Loading events...
+ ) : events.length === 0 ? ( +
+

+ {filter === 'all' ? 'No alert events' : `No ${filter} alert events`} +

+
+ ) : ( +
+ + + + + + + + + + + + {events.map((evt: SiteAlertEventResponse) => ( + + + + + + + + ))} + +
SeverityMessageTriggeredStateActions
+ + {evt.severity} + + + {evt.message} + + {timeAgo(evt.triggered_at)} + +
+ + {evt.state} +
+
+ {evt.state === 'active' ? ( + + ) : ( + + {evt.resolved_at ? timeAgo(evt.resolved_at) : ''} + {evt.resolved_by ? ` by ${evt.resolved_by}` : ''} + + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/alerts/AlertRuleFormDialog.tsx b/frontend/src/components/alerts/AlertRuleFormDialog.tsx new file mode 100644 index 0000000..a6b3b69 --- /dev/null +++ b/frontend/src/components/alerts/AlertRuleFormDialog.tsx @@ -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('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 ( + + + + {isEdit ? 'Edit Alert Rule' : 'Add Alert Rule'} + + {isEdit + ? 'Update alert rule settings.' + : 'Create a rule to trigger alerts when conditions are met.'} + + + +
+
+ + setName(e.target.value)} + placeholder="Alert when offline > 20%" + required + /> +
+ + {!isEdit && ( +
+ + +
+ )} + +
+ + setThresholdValue(e.target.value)} + placeholder={unit === 'dBm' ? '-75' : '20'} + required + /> +
+ +
+ +