Files
edgeguard-native/management-ui/src/pages/Alerts/index.tsx
noroot 4675c6062e feat(alerts): "Quittierte löschen" — Backlog war nur seitenweise raeumbar
Gemeldet als "Loeschen tut nichts". Das Loeschen hat funktioniert: der
Audit-Eintrag zeigt deleted=25 und die IDs waren danach weg. Nur war das
unsichtbar — die Kopf-Checkbox der Tabelle markiert ausschliesslich die
AKTUELLE Seite (25 Zeilen), und bei einem Backlog von ~950 optisch
identischen backend.down-Alarmen ruecken sofort die naechsten 25 nach.
Fuer den Operator sah es aus, als sei nichts passiert; um den Backlog
leerzubekommen waeren ~38 Klick-Runden noetig gewesen.

Neu: POST /alerts/events/delete-acknowledged loescht alle quittierten
Events auf einmal — Gegenstueck zum bereits vorhandenen "Alle
quittieren". Bewusst nur die quittierten: was noch niemand gesehen hat,
soll nicht per Sammelaktion verschwinden. Der Button zeigt die Anzahl
ueber den gesamten Datensatz, nicht nur die sichtbare Seite.

Ausserdem meldet das seitenweise Loeschen jetzt die Anzahl zurueck
("N Alarme geloescht") statt nur "Loeschen" und leert die Auswahl —
damit ist erkennbar, dass etwas passiert ist, auch wenn die Liste
optisch gleich aussieht.

Die automatische Aufraeumung (90 Tage) arbeitet uebrigens korrekt, sie
loescht taeglich die Eintraege die ueber die Grenze rutschen — der
Backlog stammt aus dem Zeitraum davor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 12:59:15 +02:00

546 lines
21 KiB
TypeScript

import { useMemo, useState } from 'react'
import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
BellOutlined, ExperimentOutlined, PlusOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'
import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import EmptyState from '../../components/EmptyState'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography
interface Channel {
id: number
name: string
kind: 'webhook' | 'email'
target: string
settings: Record<string, unknown>
active: boolean
}
interface SendResult {
channel_id: number
channel_name: string
ok: boolean
error?: string
}
interface AlertEvent {
id: number
kind: string
severity: 'info' | 'warning' | 'error' | 'critical'
subject: string
message: string
sent_to: SendResult[]
fired_at: string
acknowledged_at?: string | null
}
interface ChannelFormValues {
name: string
kind: 'webhook' | 'email'
target: string
active: boolean
// Email-fields (settings.*):
smtp_host?: string
smtp_port?: number
username?: string
password?: string
from?: string
use_tls?: boolean
}
function sevTag(s: AlertEvent['severity']) {
const map: Record<string, string> = {
info: 'blue', warning: 'orange', error: 'red', critical: 'magenta',
}
return <Tag color={map[s]}>{s.toUpperCase()}</Tag>
}
export default function AlertsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
// Tab über ?tab= steuerbar, damit der Dashboard-Deeplink
// (/alerts?tab=events) direkt auf der Event-History landet statt auf
// dem Channels-Tab. Default bleibt 'channels' für den Direktaufruf.
const [searchParams, setSearchParams] = useSearchParams()
const activeTab = searchParams.get('tab') === 'events' ? 'events' : 'channels'
const setActiveTab = (key: string) =>
setSearchParams(key === 'events' ? { tab: 'events' } : {}, { replace: true })
const channels = useQuery({
queryKey: ['alerts', 'channels'],
queryFn: async () => {
const r = await apiClient.get('/alerts/channels')
return isEnvelope(r.data) ? (r.data.data as { channels: Channel[] }).channels : []
},
})
const events = useQuery({
queryKey: ['alerts', 'events'],
queryFn: async () => {
const r = await apiClient.get('/alerts/events?limit=200')
return isEnvelope(r.data) ? (r.data.data as { events: AlertEvent[] }).events : []
},
refetchInterval: 15_000,
})
const [edit, setEdit] = useState<Channel | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<ChannelFormValues>()
const [filterSev, setFilterSev] = useState<string | undefined>()
const [filterKind, setFilterKind] = useState<string | undefined>()
const [selectedIds, setSelectedIds] = useState<number[]>([])
// Nach Quittieren/Löschen sowohl die Event-Liste als auch die Dashboard-
// Karte (queryKey ['alerts','events','recent']) invalidieren — Prefix-Match.
const refreshEvents = () => {
setSelectedIds([])
void qc.invalidateQueries({ queryKey: ['alerts', 'events'] })
}
const ackMut = useMutation({
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/acknowledge', { ids }),
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
onError: (e: Error) => message.error(e.message),
})
const ackAllMut = useMutation({
mutationFn: () => apiClient.post('/alerts/events/acknowledge-all'),
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
onError: (e: Error) => message.error(e.message),
})
const delEventsMut = useMutation({
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
onSuccess: (_d, ids) => {
// Anzahl mitsagen: die Kopf-Checkbox markiert nur die AKTUELLE Seite,
// und bei einem groesseren Backlog ruecken sofort optisch identische
// Eintraege nach. Ohne diese Rueckmeldung sieht es aus, als sei nichts
// passiert (Befund 2026-09-11).
message.success(t('alerts.eventsDeleted', { n: ids.length }))
setSelectedIds([])
refreshEvents()
},
onError: (e: Error) => message.error(e.message),
})
const delAckedMut = useMutation({
mutationFn: () => apiClient.post('/alerts/events/delete-acknowledged'),
onSuccess: (r) => {
const n = (r?.data as { data?: { deleted?: number } })?.data?.deleted ?? 0
message.success(t('alerts.eventsDeleted', { n }))
setSelectedIds([])
refreshEvents()
},
onError: (e: Error) => message.error(e.message),
})
// Anzahl quittierter Events ueber den GESAMTEN Datensatz, nicht nur die
// sichtbare Seite — der Button soll zeigen, was er wirklich raeumt.
const ackedCount = useMemo(
() => (events.data ?? []).filter(e => e.acknowledged_at).length,
[events.data],
)
const kindOptions = useMemo(() => {
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
return kinds.map(k => ({ value: k, label: k }))
}, [events.data])
const filteredEvents = useMemo(() =>
(events.data ?? []).filter(e =>
(!filterSev || e.severity === filterSev) &&
(!filterKind || e.kind === filterKind)
),
[events.data, filterSev, filterKind])
function buildPayload(v: ChannelFormValues): Channel {
const settings: Record<string, unknown> = {}
if (v.kind === 'email') {
settings.smtp_host = v.smtp_host
settings.smtp_port = v.smtp_port
settings.from = v.from
settings.use_tls = !!v.use_tls
if (v.username) settings.username = v.username
if (v.password) settings.password = v.password
}
return {
id: 0,
name: v.name,
kind: v.kind,
target: v.target,
settings,
active: v.active,
}
}
const create = useMutation({
mutationFn: async (v: ChannelFormValues) => {
await apiClient.post('/alerts/channels', buildPayload(v))
},
onSuccess: () => {
message.success(t('common.save'))
setCreating(false); form.resetFields()
qc.invalidateQueries({ queryKey: ['alerts', 'channels'] })
},
onError: (e: Error) => message.error(e.message),
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: ChannelFormValues }) => {
await apiClient.put(`/alerts/channels/${id}`, buildPayload(v))
},
onSuccess: () => {
message.success(t('common.save'))
setEdit(null); form.resetFields()
qc.invalidateQueries({ queryKey: ['alerts', 'channels'] })
},
onError: (e: Error) => message.error(e.message),
})
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: Channel; checked: boolean }) => {
await apiClient.put(`/alerts/channels/${id}`, { ...row, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['alerts', 'channels'] }) },
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/alerts/channels/${id}`) },
onSuccess: () => { qc.invalidateQueries({ queryKey: ['alerts', 'channels'] }) },
onError: (e: Error) => message.error(e.message),
})
const testFire = useMutation({
mutationFn: async () => {
const r = await apiClient.post('/alerts/test')
return isEnvelope(r.data) ? (r.data.data as AlertEvent) : null
},
onSuccess: (e: AlertEvent | null) => {
const total = e?.sent_to.length ?? 0
const ok = e?.sent_to.filter((r) => r.ok).length ?? 0
message.success(t('alerts.testDone', { ok, total }))
qc.invalidateQueries({ queryKey: ['alerts', 'events'] })
},
onError: (e: Error) => message.error(e.message),
})
const chanColumns: ColumnsType<Channel> = [
{ title: t('alerts.col.name'), dataIndex: 'name' },
{
title: t('alerts.col.kind'), dataIndex: 'kind', width: 110,
render: (v: string) =>
<Tag color={v === 'webhook' ? 'blue' : 'purple'}>{v}</Tag>,
},
{
title: t('alerts.col.target'), dataIndex: 'target',
render: (v: string) => <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</Text>,
},
{
title: t('alerts.col.active'), dataIndex: 'active', width: 80,
render: (v: boolean, r: Channel) => (
<Switch
size="small"
checked={v}
disabled={isViewer}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/>
),
},
{
title: t('common.actions'), key: 'a', width: 180,
render: (_, r) => (
<Space size={4}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer} onClick={() => {
if (isViewer) return
setEdit(r)
const settings = (r.settings ?? {}) as Record<string, unknown>
form.setFieldsValue({
name: r.name, kind: r.kind, target: r.target, active: r.active,
smtp_host: settings.smtp_host as string,
smtp_port: settings.smtp_port as number,
username: settings.username as string,
password: settings.password as string,
from: settings.from as string,
use_tls: settings.use_tls as boolean,
})
}}>{t('common.edit')}</Button>
</Tooltip>
{isViewer
? <Tooltip title={t('auth.viewerBadge')}><Button size="small" danger disabled>{t('common.delete')}</Button></Tooltip>
: <Popconfirm title={t('alerts.confirmDelete', { name: r.name })}
onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
}
</Space>
),
},
]
const evColumns: ColumnsType<AlertEvent> = [
{
title: t('alerts.col.time'), dataIndex: 'fired_at', width: 160,
render: (v: string) =>
<Text style={{ fontFamily: 'monospace', fontSize: 11 }}>
{dayjs(v).format('YYYY-MM-DD HH:mm:ss')}
</Text>,
},
{
title: t('alerts.col.severity'), dataIndex: 'severity', width: 110,
render: sevTag,
},
{ title: t('alerts.col.kind'), dataIndex: 'kind', width: 180,
render: (v: string) => <Tag>{v}</Tag> },
{
title: t('alerts.col.subject'), dataIndex: 'subject',
render: (s: string, r) => (
<div>
<div><Text strong>{s}</Text></div>
<Text type="secondary" style={{ fontSize: 12, whiteSpace: 'pre-wrap' }}>
{r.message}
</Text>
</div>
),
},
{
title: t('alerts.col.delivered'), dataIndex: 'sent_to', width: 200,
render: (rs: SendResult[]) => {
if (!rs || rs.length === 0)
return <Text type="secondary">{t('alerts.noChannels')}</Text>
return (
<Space size={4} wrap>
{rs.map((r) => (
<Tooltip key={r.channel_id} title={r.error || 'OK'}>
<Tag color={r.ok ? 'green' : 'red'}>{r.channel_name}</Tag>
</Tooltip>
))}
</Space>
)
},
},
{
title: t('alerts.col.status'), key: 'ack', width: 110,
render: (_, r: AlertEvent) => r.acknowledged_at
? <Tooltip title={dayjs(r.acknowledged_at).format('YYYY-MM-DD HH:mm')}>
<Tag color="green">{t('alerts.acked')}</Tag>
</Tooltip>
: <Tag color="gold">{t('alerts.open')}</Tag>,
},
]
const kind = Form.useWatch('kind', form)
const openChannelCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'webhook', active: true, smtp_port: 587, use_tls: true })
}
return (
<div>
<PageHeader
icon={<BellOutlined />}
title={t('alerts.title')}
subtitle={t('alerts.intro')}
extra={
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button icon={<ExperimentOutlined />}
onClick={() => testFire.mutate()}
loading={testFire.isPending}
disabled={isViewer}>
{t('alerts.test')}
</Button>
</Tooltip>
</Space>
}
/>
<Alert
type="info"
showIcon
className="mb-16"
message={t('alerts.scopeTitle')}
description={t('alerts.scopeDesc')}
/>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
{
key: 'channels',
label: t('alerts.tabs.channels'),
children: (
<Card size="small" extra={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" size="small" icon={<PlusOutlined />}
onClick={openChannelCreate} disabled={isViewer}>
{t('alerts.add')}
</Button>
</Tooltip>
}>
<Table size="small" rowKey="id" loading={channels.isFetching}
dataSource={channels.data ?? []} columns={chanColumns}
pagination={false}
locale={{ emptyText: (
<EmptyState
icon={<BellOutlined />}
title={t('alerts.emptyChannelsTitle')}
description={t('alerts.emptyChannelsDesc')}
action={
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" icon={<PlusOutlined />} onClick={openChannelCreate} disabled={isViewer}>
{t('alerts.add')}
</Button>
</Tooltip>
}
/>
) }} />
</Card>
),
},
{
key: 'events',
label: t('alerts.tabs.events'),
children: (
<Card size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }} className="mb-8">
<Space>
<Select
allowClear
placeholder={t('alerts.col.severity')}
style={{ width: 150 }}
value={filterSev}
onChange={setFilterSev}
options={[
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
]}
/>
<Select
allowClear
showSearch
placeholder={t('alerts.col.kind')}
style={{ width: 220 }}
value={filterKind}
onChange={setFilterKind}
options={kindOptions}
/>
</Space>
<Space>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" disabled={isViewer || selectedIds.length === 0}
loading={ackMut.isPending} onClick={() => ackMut.mutate(selectedIds)}>
{t('alerts.acknowledge')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
</Button>
</Tooltip>
<Popconfirm title={t('alerts.confirmDeleteEvents', { n: selectedIds.length })}
onConfirm={() => delEventsMut.mutate(selectedIds)}
disabled={isViewer || selectedIds.length === 0}>
<Button size="small" danger disabled={isViewer || selectedIds.length === 0}
loading={delEventsMut.isPending}>
{t('common.delete')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
</Button>
</Popconfirm>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button size="small" type="primary" ghost disabled={isViewer}
loading={ackAllMut.isPending} onClick={() => ackAllMut.mutate()}>
{t('alerts.acknowledgeAll')}
</Button>
</Tooltip>
{/* Gegenstueck zu "Alle quittieren": ohne das kommt man an
einen groesseren Backlog nur seitenweise heran, weil die
Kopf-Checkbox der Tabelle nur die aktuelle Seite markiert. */}
<Popconfirm title={t('alerts.confirmDeleteAcked', { n: ackedCount })}
onConfirm={() => delAckedMut.mutate()}
disabled={isViewer || ackedCount === 0}>
<Button size="small" danger ghost
disabled={isViewer || ackedCount === 0}
loading={delAckedMut.isPending}>
{t('alerts.deleteAcknowledged')}{ackedCount > 0 ? ` (${ackedCount})` : ''}
</Button>
</Popconfirm>
</Space>
</div>
<Table size="small" rowKey="id" loading={events.isFetching}
dataSource={filteredEvents} columns={evColumns}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys as number[]),
}}
rowClassName={(r) => (r.acknowledged_at ? 'eg-row-muted' : '')}
pagination={{ defaultPageSize: 25 }}
locale={{ emptyText: (
<EmptyState
icon={<BellOutlined />}
title={t('alerts.emptyEventsTitle')}
description={t('alerts.emptyEventsDesc')}
/>
) }} />
</Card>
),
},
]} />
<Modal
title={edit ? t('alerts.editTitle') : t('alerts.addTitle')}
open={edit !== null || creating}
onCancel={() => { setEdit(null); setCreating(false); form.resetFields() }}
onOk={() => form.submit()}
width={560}
confirmLoading={create.isPending || update.isPending}
>
<Form form={form} layout="vertical"
onFinish={(v) => edit ? update.mutate({ id: edit.id, v }) : create.mutate(v)}>
<Form.Item label={t('alerts.col.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="Ops-Slack / Oncall-Email" />
</Form.Item>
<Form.Item label={t('alerts.col.kind')} name="kind" rules={[{ required: true }]}>
<Select options={[
{ value: 'webhook', label: t('alerts.kindWebhook') },
{ value: 'email', label: t('alerts.kindEmail') },
]} />
</Form.Item>
<Form.Item label={kind === 'email' ? t('alerts.col.targetEmail') : t('alerts.col.targetWebhook')}
name="target" rules={[{ required: true }]}>
<Input placeholder={kind === 'email' ? 'oncall@example.com' : 'https://hooks.slack.com/services/...'} />
</Form.Item>
{kind === 'email' && (
<>
<Form.Item label={t('alerts.smtp.host')} name="smtp_host" rules={[{ required: true }]}>
<Input placeholder="smtp.gmail.com" />
</Form.Item>
<Form.Item label={t('alerts.smtp.port')} name="smtp_port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('alerts.smtp.from')} name="from" rules={[{ required: true }]}>
<Input placeholder="edgeguard@your-domain.tld" />
</Form.Item>
<Form.Item label={t('alerts.smtp.username')} name="username">
<Input />
</Form.Item>
<Form.Item label={t('alerts.smtp.password')} name="password">
<Input.Password />
</Form.Item>
<Form.Item label={t('alerts.smtp.starttls')} name="use_tls" valuePropName="checked">
<Switch />
</Form.Item>
</>
)}
<Form.Item label={t('alerts.col.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
)
}