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:
351
management-ui/src/pages/Alerts/index.tsx
Normal file
351
management-ui/src/pages/Alerts/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user