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 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 = { info: 'blue', warning: 'orange', error: 'red', critical: 'magenta', } return {s.toUpperCase()} } 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(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const [filterSev, setFilterSev] = useState() const [filterKind, setFilterKind] = useState() const [selectedIds, setSelectedIds] = useState([]) // 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 = {} 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 = [ { title: t('alerts.col.name'), dataIndex: 'name' }, { title: t('alerts.col.kind'), dataIndex: 'kind', width: 110, render: (v: string) => {v}, }, { title: t('alerts.col.target'), dataIndex: 'target', render: (v: string) => {v}, }, { title: t('alerts.col.active'), dataIndex: 'active', width: 80, render: (v: boolean, r: Channel) => ( quickToggle.mutate({ id: r.id, row: r, checked })} /> ), }, { title: t('common.actions'), key: 'a', width: 180, render: (_, r) => ( {isViewer ? : del.mutate(r.id)}> } ), }, ] const evColumns: ColumnsType = [ { title: t('alerts.col.time'), dataIndex: 'fired_at', width: 160, render: (v: string) => {dayjs(v).format('YYYY-MM-DD HH:mm:ss')} , }, { title: t('alerts.col.severity'), dataIndex: 'severity', width: 110, render: sevTag, }, { title: t('alerts.col.kind'), dataIndex: 'kind', width: 180, render: (v: string) => {v} }, { title: t('alerts.col.subject'), dataIndex: 'subject', render: (s: string, r) => (
{s}
{r.message}
), }, { title: t('alerts.col.delivered'), dataIndex: 'sent_to', width: 200, render: (rs: SendResult[]) => { if (!rs || rs.length === 0) return {t('alerts.noChannels')} return ( {rs.map((r) => ( {r.channel_name} ))} ) }, }, { title: t('alerts.col.status'), key: 'ack', width: 110, render: (_, r: AlertEvent) => r.acknowledged_at ? {t('alerts.acked')} : {t('alerts.open')}, }, ] 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 (
} title={t('alerts.title')} subtitle={t('alerts.intro')} extra={ } /> }> } title={t('alerts.emptyChannelsTitle')} description={t('alerts.emptyChannelsDesc')} action={ } /> ) }} /> ), }, { key: 'events', label: t('alerts.tabs.events'), children: (
delEventsMut.mutate(selectedIds)} disabled={isViewer || selectedIds.length === 0}> {/* 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. */} delAckedMut.mutate()} disabled={isViewer || ackedCount === 0}>
setSelectedIds(keys as number[]), }} rowClassName={(r) => (r.acknowledged_at ? 'eg-row-muted' : '')} pagination={{ defaultPageSize: 25 }} locale={{ emptyText: ( } title={t('alerts.emptyEventsTitle')} description={t('alerts.emptyEventsDesc')} /> ) }} /> ), }, ]} /> { setEdit(null); setCreating(false); form.resetFields() }} onOk={() => form.submit()} width={560} confirmLoading={create.isPending || update.isPending} >
edit ? update.mutate({ id: edit.id, v }) : create.mutate(v)}> {kind === 'email' && ( <> )}
) }