feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
31
frontend/src/components/alerts/AlertBadge.tsx
Normal file
31
frontend/src/components/alerts/AlertBadge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* AlertBadge — renders a red badge with active alert count.
|
||||
* Polls every 30 seconds. Returns null if count is 0.
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { alertsApi } from '@/lib/alertsApi'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
|
||||
export function AlertBadge() {
|
||||
const { user } = useAuth()
|
||||
const tenantId = user?.tenant_id
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['alert-active-count', tenantId],
|
||||
queryFn: () => alertsApi.getActiveAlertCount(tenantId!),
|
||||
enabled: !!tenantId,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
if (!data?.count) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className="bg-error text-text-primary text-xs font-medium rounded-full px-1.5 py-0.5 leading-none min-w-[1.25rem] text-center"
|
||||
title={`${data.count} active alert${data.count === 1 ? '' : 's'}`}
|
||||
>
|
||||
{data.count > 99 ? '99+' : data.count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
907
frontend/src/components/alerts/AlertRulesPage.tsx
Normal file
907
frontend/src/components/alerts/AlertRulesPage.tsx
Normal file
@@ -0,0 +1,907 @@
|
||||
/**
|
||||
* AlertRulesPage — Alert rules and notification channels management.
|
||||
* Two-section page: rules table (top) and channels cards (bottom).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
BellRing,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Send,
|
||||
Mail,
|
||||
Globe,
|
||||
Hash,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
alertsApi,
|
||||
type AlertRule,
|
||||
type NotificationChannel,
|
||||
type AlertRuleCreateData,
|
||||
type ChannelCreateData,
|
||||
} from '@/lib/alertsApi'
|
||||
import { devicesApi, deviceGroupsApi } from '@/lib/api'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SMTP_PRESETS } from '@/lib/smtpPresets'
|
||||
|
||||
const METRICS = [
|
||||
{ value: 'cpu_load', label: 'CPU Load (%)' },
|
||||
{ value: 'memory_used_pct', label: 'Memory Used (%)' },
|
||||
{ value: 'disk_used_pct', label: 'Disk Used (%)' },
|
||||
{ value: 'temperature', label: 'Temperature' },
|
||||
{ value: 'signal_strength', label: 'Signal Strength (dBm)' },
|
||||
{ value: 'ccq', label: 'CCQ (%)' },
|
||||
{ value: 'client_count', label: 'Client Count' },
|
||||
]
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: 'gt', label: '>' },
|
||||
{ value: 'lt', label: '<' },
|
||||
{ value: 'gte', label: '>=' },
|
||||
{ value: 'lte', label: '<=' },
|
||||
]
|
||||
|
||||
function operatorLabel(op: string): string {
|
||||
return OPERATORS.find((o) => o.value === op)?.label ?? op
|
||||
}
|
||||
|
||||
function metricLabel(metric: string): string {
|
||||
return METRICS.find((m) => m.value === metric)?.label ?? metric
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config: Record<string, string> = {
|
||||
critical: 'bg-error/20 text-error border-error/40',
|
||||
warning: 'bg-warning/20 text-warning border-warning/40',
|
||||
info: 'bg-info/20 text-info border-info/40',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
|
||||
config[severity] ?? config.info,
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rule Form Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RuleFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
tenantId,
|
||||
rule,
|
||||
channels,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
tenantId: string
|
||||
rule: AlertRule | null
|
||||
channels: NotificationChannel[]
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = !!rule
|
||||
|
||||
const [name, setName] = useState(rule?.name ?? '')
|
||||
const [metric, setMetric] = useState(rule?.metric ?? 'cpu_load')
|
||||
const [operator, setOperator] = useState(rule?.operator ?? 'gt')
|
||||
const [threshold, setThreshold] = useState(String(rule?.threshold ?? 90))
|
||||
const [durationPolls, setDurationPolls] = useState(String(rule?.duration_polls ?? 3))
|
||||
const [severity, setSeverity] = useState(rule?.severity ?? 'warning')
|
||||
const [enabled, setEnabled] = useState(rule?.enabled ?? true)
|
||||
const [selectedChannels, setSelectedChannels] = useState<string[]>(rule?.channel_ids ?? [])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: AlertRuleCreateData) => alertsApi.createAlertRule(tenantId, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
toast({ title: 'Alert rule created' })
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to create rule', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: AlertRuleCreateData) =>
|
||||
alertsApi.updateAlertRule(tenantId, rule!.id, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
toast({ title: 'Alert rule updated' })
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to update rule', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const data: AlertRuleCreateData = {
|
||||
name,
|
||||
metric,
|
||||
operator,
|
||||
threshold: Number(threshold),
|
||||
duration_polls: Number(durationPolls),
|
||||
severity,
|
||||
enabled,
|
||||
channel_ids: selectedChannels,
|
||||
}
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(data)
|
||||
} else {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChannel = (id: string) => {
|
||||
setSelectedChannels((prev) =>
|
||||
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Alert Rule' : 'New Alert Rule'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="rule-name">Name</Label>
|
||||
<Input
|
||||
id="rule-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="High CPU usage"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>Metric</Label>
|
||||
<Select value={metric} onValueChange={setMetric}>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{METRICS.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Operator</Label>
|
||||
<Select value={operator} onValueChange={setOperator}>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Threshold</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Duration (consecutive checks)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationPolls}
|
||||
onChange={(e) => setDurationPolls(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-[10px] text-text-muted mt-0.5">
|
||||
Alert fires after threshold exceeded for this many poll cycles (~{Number(durationPolls) || 1} min)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Severity</Label>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-2 block">Notification channels</Label>
|
||||
<div className="space-y-2">
|
||||
{channels.map((ch) => (
|
||||
<label
|
||||
key={ch.id}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedChannels.includes(ch.id)}
|
||||
onCheckedChange={() => toggleChannel(ch.id)}
|
||||
/>
|
||||
<span className="text-text-secondary">{ch.name}</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
({ch.channel_type})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(!!v)}
|
||||
id="rule-enabled"
|
||||
/>
|
||||
<Label htmlFor="rule-enabled">Enabled</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel Form Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChannelFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
tenantId,
|
||||
channel,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
tenantId: string
|
||||
channel: NotificationChannel | null
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const isEdit = !!channel
|
||||
|
||||
const [channelType, setChannelType] = useState<string>(channel?.channel_type ?? 'email')
|
||||
const [name, setName] = useState(channel?.name ?? '')
|
||||
// Email fields
|
||||
const [smtpHost, setSmtpHost] = useState(channel?.smtp_host ?? '')
|
||||
const [smtpPort, setSmtpPort] = useState(String(channel?.smtp_port ?? 587))
|
||||
const [smtpUser, setSmtpUser] = useState(channel?.smtp_user ?? '')
|
||||
const [smtpPassword, setSmtpPassword] = useState('')
|
||||
const [smtpUseTls, setSmtpUseTls] = useState(channel?.smtp_use_tls ?? true)
|
||||
const [fromAddress, setFromAddress] = useState(channel?.from_address ?? '')
|
||||
const [toAddress, setToAddress] = useState(channel?.to_address ?? '')
|
||||
// Provider preset
|
||||
const [smtpProvider, setSmtpProvider] = useState('custom')
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [testing, setTesting] = useState(false)
|
||||
// Webhook fields
|
||||
const [webhookUrl, setWebhookUrl] = useState(channel?.webhook_url ?? '')
|
||||
// Slack fields
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState(channel?.slack_webhook_url ?? '')
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
setSmtpProvider(providerId)
|
||||
const preset = SMTP_PRESETS.find((p) => p.id === providerId)
|
||||
if (preset && providerId !== 'custom') {
|
||||
setSmtpHost(preset.host)
|
||||
setSmtpPort(String(preset.port))
|
||||
setSmtpUseTls(preset.useTls)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await alertsApi.testSmtp(tenantId, {
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: Number(smtpPort),
|
||||
smtp_user: smtpUser || undefined,
|
||||
smtp_password: smtpPassword || undefined,
|
||||
smtp_use_tls: smtpUseTls,
|
||||
from_address: fromAddress || 'alerts@example.com',
|
||||
to_address: toAddress,
|
||||
})
|
||||
setTestResult(result)
|
||||
} catch (e: any) {
|
||||
setTestResult({ success: false, message: e.response?.data?.detail || e.message })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: ChannelCreateData) => alertsApi.createChannel(tenantId, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['notification-channels'] })
|
||||
toast({ title: 'Channel created' })
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to create channel', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: ChannelCreateData) =>
|
||||
alertsApi.updateChannel(tenantId, channel!.id, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['notification-channels'] })
|
||||
toast({ title: 'Channel updated' })
|
||||
onClose()
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to update channel', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const data: ChannelCreateData = {
|
||||
name,
|
||||
channel_type: channelType as 'email' | 'webhook' | 'slack',
|
||||
...(channelType === 'email'
|
||||
? {
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: Number(smtpPort),
|
||||
smtp_user: smtpUser,
|
||||
...(smtpPassword ? { smtp_password: smtpPassword } : {}),
|
||||
smtp_use_tls: smtpUseTls,
|
||||
from_address: fromAddress,
|
||||
to_address: toAddress,
|
||||
}
|
||||
: channelType === 'slack'
|
||||
? {
|
||||
slack_webhook_url: slackWebhookUrl,
|
||||
}
|
||||
: {
|
||||
webhook_url: webhookUrl,
|
||||
}),
|
||||
}
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(data)
|
||||
} else {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Edit Notification Channel' : 'New Notification Channel'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ops email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs value={channelType} onValueChange={setChannelType}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="email" className="flex-1">
|
||||
<Mail className="h-3 w-3 mr-1" /> Email
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="webhook" className="flex-1">
|
||||
<Globe className="h-3 w-3 mr-1" /> Webhook
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="slack" className="flex-1">
|
||||
<Hash className="h-3 w-3 mr-1" /> Slack
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="email" className="mt-3 space-y-3">
|
||||
<div>
|
||||
<Label>Email Provider</Label>
|
||||
<select
|
||||
value={smtpProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="w-full rounded-md bg-slate-700 border border-slate-600 text-white px-3 py-2 text-sm"
|
||||
>
|
||||
{SMTP_PRESETS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{SMTP_PRESETS.find((p) => p.id === smtpProvider)?.helpText}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>SMTP Host</Label>
|
||||
<Input
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="smtp.gmail.com"
|
||||
readOnly={smtpProvider !== 'custom'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
readOnly={smtpProvider !== 'custom'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
value={smtpUser}
|
||||
onChange={(e) => setSmtpUser(e.target.value)}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={smtpPassword}
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
placeholder={isEdit ? '(unchanged)' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>From</Label>
|
||||
<Input
|
||||
value={fromAddress}
|
||||
onChange={(e) => setFromAddress(e.target.value)}
|
||||
placeholder="alerts@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>To</Label>
|
||||
<Input
|
||||
value={toAddress}
|
||||
onChange={(e) => setToAddress(e.target.value)}
|
||||
placeholder="ops@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={smtpUseTls}
|
||||
onCheckedChange={(v) => setSmtpUseTls(!!v)}
|
||||
id="smtp-tls"
|
||||
disabled={smtpProvider !== 'custom'}
|
||||
/>
|
||||
<Label htmlFor="smtp-tls">Use TLS</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testing || !smtpHost || !toAddress}
|
||||
className="px-4 py-2 rounded-md bg-slate-600 text-white text-sm hover:bg-slate-500 disabled:opacity-50"
|
||||
>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<p className={`text-sm ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="webhook" className="mt-3">
|
||||
<div>
|
||||
<Label>Webhook URL</Label>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="slack" className="mt-3 space-y-3">
|
||||
<p className="text-xs text-text-muted">
|
||||
Create an Incoming Webhook in your Slack workspace settings, then paste the URL here.
|
||||
</p>
|
||||
<div>
|
||||
<Label>Slack Webhook URL</Label>
|
||||
<Input
|
||||
value={slackWebhookUrl}
|
||||
onChange={(e) => setSlackWebhookUrl(e.target.value)}
|
||||
placeholder="https://hooks.slack.com/services/T.../B.../..."
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AlertRulesPage() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [ruleDialog, setRuleDialog] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<AlertRule | null>(null)
|
||||
const [channelDialog, setChannelDialog] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null)
|
||||
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
const { data: rules = [] } = useQuery({
|
||||
queryKey: ['alert-rules', tenantId],
|
||||
queryFn: () => alertsApi.getAlertRules(tenantId),
|
||||
enabled: !!tenantId,
|
||||
})
|
||||
|
||||
const { data: channels = [] } = useQuery({
|
||||
queryKey: ['notification-channels', tenantId],
|
||||
queryFn: () => alertsApi.getNotificationChannels(tenantId),
|
||||
enabled: !!tenantId,
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (ruleId: string) => alertsApi.toggleAlertRule(tenantId, ruleId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to toggle rule', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const deleteRuleMutation = useMutation({
|
||||
mutationFn: (ruleId: string) => alertsApi.deleteAlertRule(tenantId, ruleId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
toast({ title: 'Rule deleted' })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to delete rule', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const deleteChannelMutation = useMutation({
|
||||
mutationFn: (channelId: string) => alertsApi.deleteChannel(tenantId, channelId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['notification-channels'] })
|
||||
toast({ title: 'Channel deleted' })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to delete channel', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const testChannelMutation = useMutation({
|
||||
mutationFn: (channelId: string) => alertsApi.testChannel(tenantId, channelId),
|
||||
onSuccess: () => toast({ title: 'Test notification sent successfully' }),
|
||||
onError: () =>
|
||||
toast({ title: 'Test notification failed', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<BellRing className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-lg font-semibold">Alert Rules</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3" />
|
||||
</div>
|
||||
|
||||
{/* ── Alert Rules Section ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-text-secondary">Threshold Rules</h2>
|
||||
{canWrite(user) && tenantId && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingRule(null)
|
||||
setRuleDialog(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add Rule
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!tenantId ? (
|
||||
<p className="text-sm text-text-muted py-6 text-center">
|
||||
Select an organization from the header to manage alert rules.
|
||||
</p>
|
||||
) : rules.length === 0 ? (
|
||||
<p className="text-sm text-text-muted py-6 text-center">
|
||||
No alert rules configured.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-xs text-text-muted font-medium">
|
||||
<span className="flex-1">Name</span>
|
||||
<span className="w-40">Condition</span>
|
||||
<span className="w-16">Severity</span>
|
||||
<span className="w-16 text-center">Enabled</span>
|
||||
<span className="w-20" />
|
||||
</div>
|
||||
{rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-text-primary truncate block">
|
||||
{rule.name}
|
||||
{rule.is_default && (
|
||||
<span className="text-xs text-text-muted ml-2">(default)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-40 text-xs text-text-muted font-mono">
|
||||
{metricLabel(rule.metric)} {operatorLabel(rule.operator)}{' '}
|
||||
{rule.threshold} for {rule.duration_polls}
|
||||
</span>
|
||||
<span className="w-16">
|
||||
<SeverityBadge severity={rule.severity} />
|
||||
</span>
|
||||
<span className="w-16 text-center">
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate(rule.id)}
|
||||
className={cn(
|
||||
'w-8 h-4 rounded-full relative transition-colors',
|
||||
rule.enabled ? 'bg-success' : 'bg-border',
|
||||
)}
|
||||
disabled={!canWrite(user)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 w-3 h-3 rounded-full bg-white transition-all',
|
||||
rule.enabled ? 'left-4' : 'left-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
<span className="w-20 flex items-center gap-1 justify-end">
|
||||
{canWrite(user) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingRule(rule)
|
||||
setRuleDialog(true)
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-text-secondary"
|
||||
title="Edit rule"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!rule.is_default && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete rule "${rule.name}"?`)) {
|
||||
deleteRuleMutation.mutate(rule.id)
|
||||
}
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-error"
|
||||
title="Delete rule"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Notification Channels Section ── */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-text-secondary">Notification Channels</h2>
|
||||
{canWrite(user) && tenantId && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingChannel(null)
|
||||
setChannelDialog(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Add Channel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!tenantId ? (
|
||||
<p className="text-sm text-text-muted py-6 text-center">
|
||||
Select an organization from the header to manage channels.
|
||||
</p>
|
||||
) : channels.length === 0 ? (
|
||||
<p className="text-sm text-text-muted py-6 text-center">
|
||||
No notification channels configured.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{channels.map((ch) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="rounded-lg border border-border bg-surface p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{ch.channel_type === 'email' ? (
|
||||
<Mail className="h-4 w-4 text-info" />
|
||||
) : ch.channel_type === 'slack' ? (
|
||||
<Hash className="h-4 w-4 text-chart-4" />
|
||||
) : (
|
||||
<Globe className="h-4 w-4 text-chart-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-text-primary">{ch.name}</span>
|
||||
<span className="text-[10px] uppercase text-text-muted border border-border rounded px-1">
|
||||
{ch.channel_type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
{ch.channel_type === 'email'
|
||||
? ch.to_address ?? ch.from_address ?? 'No address'
|
||||
: ch.channel_type === 'slack'
|
||||
? ch.slack_webhook_url
|
||||
? ch.slack_webhook_url.slice(0, 50) + '...'
|
||||
: 'No URL'
|
||||
: ch.webhook_url
|
||||
? ch.webhook_url.slice(0, 50) + '...'
|
||||
: 'No URL'}
|
||||
</p>
|
||||
{canWrite(user) && (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => testChannelMutation.mutate(ch.id)}
|
||||
disabled={testChannelMutation.isPending}
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
Test
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingChannel(ch)
|
||||
setChannelDialog(true)
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-text-secondary"
|
||||
title="Edit channel"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete channel "${ch.name}"?`)) {
|
||||
deleteChannelMutation.mutate(ch.id)
|
||||
}
|
||||
}}
|
||||
className="p-1 text-text-muted hover:text-error"
|
||||
title="Delete channel"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Dialogs */}
|
||||
{ruleDialog && (
|
||||
<RuleFormDialog
|
||||
open={ruleDialog}
|
||||
onClose={() => {
|
||||
setRuleDialog(false)
|
||||
setEditingRule(null)
|
||||
}}
|
||||
tenantId={tenantId}
|
||||
rule={editingRule}
|
||||
channels={channels}
|
||||
/>
|
||||
)}
|
||||
{channelDialog && (
|
||||
<ChannelFormDialog
|
||||
open={channelDialog}
|
||||
onClose={() => {
|
||||
setChannelDialog(false)
|
||||
setEditingChannel(null)
|
||||
}}
|
||||
tenantId={tenantId}
|
||||
channel={editingChannel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
396
frontend/src/components/alerts/AlertsPage.tsx
Normal file
396
frontend/src/components/alerts/AlertsPage.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* AlertsPage — Active alerts and alert history with filtering, acknowledge, and silence.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
BellRing,
|
||||
Building2,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { alertsApi, type AlertEvent, type AlertsFilterParams } from '@/lib/alertsApi'
|
||||
import { useAuth, isSuperAdmin } from '@/lib/auth'
|
||||
import { useUIStore } from '@/lib/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn, formatDateTime } from '@/lib/utils'
|
||||
import { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config: Record<string, string> = {
|
||||
critical: 'bg-error/20 text-error border-error/40',
|
||||
warning: 'bg-warning/20 text-warning border-warning/40',
|
||||
info: 'bg-info/20 text-info border-info/40',
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
|
||||
config[severity] ?? config.info,
|
||||
)}
|
||||
>
|
||||
{severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: string }) {
|
||||
if (status === 'firing') return <BellRing className="h-4 w-4 text-error" />
|
||||
if (status === 'resolved') return <CheckCircle className="h-4 w-4 text-success" />
|
||||
if (status === 'flapping') return <AlertTriangle className="h-4 w-4 text-warning" />
|
||||
return <Bell className="h-4 w-4 text-text-muted" />
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
function AlertRow({
|
||||
alert,
|
||||
tenantId,
|
||||
onAcknowledge,
|
||||
onSilence,
|
||||
}: {
|
||||
alert: AlertEvent
|
||||
tenantId: string
|
||||
onAcknowledge: (alertId: string) => void
|
||||
onSilence: (alertId: string, minutes: number) => void
|
||||
}) {
|
||||
const isSilenced =
|
||||
alert.silenced_until && new Date(alert.silenced_until) > new Date()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border/50 hover:bg-surface transition-colors">
|
||||
<StatusIcon status={alert.status} />
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-primary truncate">
|
||||
{alert.message ?? `${alert.metric} ${alert.value ?? ''}`}
|
||||
</span>
|
||||
{alert.is_flapping && (
|
||||
<span className="text-[10px] text-warning/80 border border-warning/40 rounded px-1">
|
||||
flapping
|
||||
</span>
|
||||
)}
|
||||
{isSilenced && <BellOff className="h-3 w-3 text-text-muted" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-text-muted mt-0.5">
|
||||
<Link
|
||||
to="/tenants/$tenantId/devices/$deviceId"
|
||||
params={{ tenantId, deviceId: alert.device_id }}
|
||||
className="hover:text-text-primary"
|
||||
>
|
||||
{alert.device_hostname ?? alert.device_id.slice(0, 8)}
|
||||
</Link>
|
||||
{alert.rule_name && <span>{alert.rule_name}</span>}
|
||||
{alert.threshold != null && (
|
||||
<span>
|
||||
{alert.value != null ? alert.value.toFixed(1) : '?'} / {alert.threshold}
|
||||
</span>
|
||||
)}
|
||||
<span>{timeAgo(alert.fired_at)}</span>
|
||||
{alert.resolved_at && (
|
||||
<span className="text-success/60">
|
||||
resolved {timeAgo(alert.resolved_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{alert.status === 'firing' && !alert.acknowledged_at && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => onAcknowledge(alert.id)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{alert.status === 'firing' && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
<BellOff className="h-3 w-3 mr-1" />
|
||||
Silence
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onSilence(alert.id, 15)}>15 min</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onSilence(alert.id, 60)}>1 hour</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onSilence(alert.id, 240)}>4 hours</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onSilence(alert.id, 480)}>8 hours</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onSilence(alert.id, 1440)}>24 hours</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AlertsPage() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState('active')
|
||||
const [severity, setSeverity] = useState<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// For super_admin, use global org context; for normal users, use their tenant
|
||||
const { selectedTenantId } = useUIStore()
|
||||
|
||||
const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '')
|
||||
|
||||
// Build filter params
|
||||
const params: AlertsFilterParams = {
|
||||
page,
|
||||
per_page: 50,
|
||||
}
|
||||
if (tab === 'active') {
|
||||
params.status = 'firing'
|
||||
}
|
||||
if (severity) {
|
||||
params.severity = severity
|
||||
}
|
||||
|
||||
const { data: alertsData, isLoading } = useQuery({
|
||||
queryKey: ['alerts', tenantId, tab, severity, page],
|
||||
queryFn: () => alertsApi.getAlerts(tenantId, params),
|
||||
enabled: !!tenantId,
|
||||
refetchInterval: tab === 'active' ? 30_000 : undefined,
|
||||
})
|
||||
|
||||
const acknowledgeMutation = useMutation({
|
||||
mutationFn: (alertId: string) => alertsApi.acknowledgeAlert(tenantId, alertId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
|
||||
toast({ title: 'Alert acknowledged' })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to acknowledge', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const silenceMutation = useMutation({
|
||||
mutationFn: ({ alertId, minutes }: { alertId: string; minutes: number }) =>
|
||||
alertsApi.silenceAlert(tenantId, alertId, minutes),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['alert-active-count'] })
|
||||
toast({ title: 'Alert silenced' })
|
||||
},
|
||||
onError: () => toast({ title: 'Failed to silence', variant: 'destructive' }),
|
||||
})
|
||||
|
||||
const alerts = alertsData?.items ?? []
|
||||
const total = alertsData?.total ?? 0
|
||||
const totalPages = Math.ceil(total / 50)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="h-5 w-5 text-text-muted" />
|
||||
<h1 className="text-lg font-semibold">Alerts</h1>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={severity}
|
||||
onValueChange={(v) => {
|
||||
setSeverity(v === 'all' ? '' : v)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36 h-8 text-xs">
|
||||
<SelectValue placeholder="All severities" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All severities</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => { setTab(v); setPage(1) }}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">
|
||||
Active
|
||||
{tab === 'active' && total > 0 && (
|
||||
<span className="ml-2 bg-error/20 text-error text-xs px-1.5 rounded-full">
|
||||
{total}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active" className="mt-3">
|
||||
{!tenantId ? (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-text-muted">
|
||||
<Building2 className="h-8 w-8" />
|
||||
<p className="text-sm">Select an organization from the header to view alerts.</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BellOff}
|
||||
title="No active alerts"
|
||||
description="All clear! No alerts have been triggered."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{alerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
tenantId={tenantId}
|
||||
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
||||
onSilence={(id, mins) =>
|
||||
silenceMutation.mutate({ alertId: id, minutes: mins })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="mt-3">
|
||||
{!tenantId ? (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-text-muted">
|
||||
<Building2 className="h-8 w-8" />
|
||||
<p className="text-sm">Select an organization from the header to view alerts.</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BellOff}
|
||||
title="No alert history"
|
||||
description="Alert events will appear here as they are triggered and resolved."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface overflow-hidden">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-xs text-text-muted font-medium">
|
||||
<span className="w-5" />
|
||||
<span className="w-16">Severity</span>
|
||||
<span className="w-16">Status</span>
|
||||
<span className="flex-1">Details</span>
|
||||
<span className="w-24">Fired</span>
|
||||
<span className="w-24">Resolved</span>
|
||||
</div>
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border/50 hover:bg-surface text-sm"
|
||||
>
|
||||
<StatusIcon status={alert.status} />
|
||||
<span className="w-16">
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'w-16 text-xs',
|
||||
alert.status === 'firing'
|
||||
? 'text-error'
|
||||
: alert.status === 'resolved'
|
||||
? 'text-success'
|
||||
: 'text-warning',
|
||||
)}
|
||||
>
|
||||
{alert.status}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-text-primary truncate block">
|
||||
{alert.message ?? alert.metric ?? 'System alert'}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{alert.device_hostname ?? alert.device_id.slice(0, 8)}
|
||||
{alert.rule_name && ` — ${alert.rule_name}`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-24 text-xs text-text-muted">
|
||||
{formatDateTime(alert.fired_at)}
|
||||
</span>
|
||||
<span className="w-24 text-xs text-text-muted">
|
||||
{alert.resolved_at ? formatDateTime(alert.resolved_at) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-text-muted">
|
||||
{total} alert{total !== 1 ? 's' : ''} total
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user