feat(alerts): Health-Alarme via Webhook + Email-SMTP

Sidebar → System → Alarme.

Migration 0021: alert_channels (kind=webhook|email, target, settings,
active) + alert_events (kind, severity=info/warning/error/critical,
subject, message, sent_to JSONB).

internal/services/alerts/:
  - Fire(kind, severity, subject, message) — broadcastet an alle
    aktiven Channels + persistiert Event mit per-Channel-Result
    (ok/error) in sent_to.
  - Webhook-Sender: POST JSON {kind, severity, subject, message,
    content, text, fired_at}. Slack/Discord/Teams akzeptieren das
    out-of-the-box ohne Adapter (content + text-Felder gleichzeitig).
  - Email-Sender: net/smtp + STARTTLS optional. Settings (smtp_host,
    smtp_port, username/password, from, use_tls) liegen in
    channel.settings JSONB.

internal/handlers/alerts.go: CRUD + POST /alerts/test + GET
/alerts/events (history).

Scheduler-Trigger:
  - cert.expiring  — TLS-Cert <14 Tage Restzeit (12h-dedupe pro cert)
                     severity warning, <3 Tage → error
  - cert.renew_failed       — Renewer-Cycle hat fails
  - cert.renewer.run_failed — Renewer-Cycle abgebrochen
  - backup.failed  — Scheduled Backup error
  - license.invalid — License-Server liefert valid=false

In-process Dedupe (12h TTL, map[key]time.Time) verhindert dass
identische Alerts in Schleifen feuern.

UI (pages/Alerts): Tabs Channels (CRUD-Tabelle, Add-Modal mit
conditional-Email-Fields) + History (200 letzte Events mit
severity-Tag + per-Channel-Delivery-Status). Header-Button
„Test-Alert" feuert einen Test-Event in alle aktiven Channels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 15:57:05 +02:00
parent 4a34629023
commit 81a8217493
13 changed files with 1012 additions and 14 deletions

View File

@@ -0,0 +1,351 @@
import { 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 dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
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
}
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 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>()
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 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) =>
v ? <Tag color="green">an</Tag> : <Tag>aus</Tag>,
},
{
title: t('common.actions'), key: 'a', width: 180,
render: (_, r) => (
<Space size={4}>
<Button size="small" onClick={() => {
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>
<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>
)
},
},
]
const kind = Form.useWatch('kind', form)
return (
<div>
<PageHeader
icon={<BellOutlined />}
title={t('alerts.title')}
subtitle={t('alerts.intro')}
extra={
<Space>
<Button icon={<ExperimentOutlined />}
onClick={() => testFire.mutate()}
loading={testFire.isPending}>
{t('alerts.test')}
</Button>
</Space>
}
/>
<Alert
type="info"
showIcon
className="mb-16"
message={t('alerts.scopeTitle')}
description={t('alerts.scopeDesc')}
/>
<Tabs items={[
{
key: 'channels',
label: t('alerts.tabs.channels'),
children: (
<Card size="small" extra={
<Button type="primary" size="small" icon={<PlusOutlined />}
onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'webhook', active: true, smtp_port: 587, use_tls: true })
}}>
{t('alerts.add')}
</Button>
}>
<Table size="small" rowKey="id" loading={channels.isFetching}
dataSource={channels.data ?? []} columns={chanColumns}
pagination={false}
locale={{ emptyText: t('alerts.emptyChannels') }} />
</Card>
),
},
{
key: 'events',
label: t('alerts.tabs.events'),
children: (
<Card size="small">
<Table size="small" rowKey="id" loading={events.isFetching}
dataSource={events.data ?? []} columns={evColumns}
pagination={{ pageSize: 25 }}
locale={{ emptyText: t('alerts.emptyEvents') }} />
</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: 'Webhook (Slack/Discord/Teams/HTTP-Endpoint)' },
{ value: 'email', label: 'Email (SMTP)' },
]} />
</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="SMTP-Host" name="smtp_host" rules={[{ required: true }]}>
<Input placeholder="smtp.gmail.com" />
</Form.Item>
<Form.Item label="SMTP-Port" name="smtp_port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="From" name="from" rules={[{ required: true }]}>
<Input placeholder="edgeguard@your-domain.tld" />
</Form.Item>
<Form.Item label="Username" name="username">
<Input />
</Form.Item>
<Form.Item label="Password" name="password">
<Input.Password />
</Form.Item>
<Form.Item label="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>
)
}