Files
edgeguard-native/management-ui/src/pages/Alerts/index.tsx
Debian bb14d2ac6c fix(i18n): hardkodierte Strings in Cluster/Users/Backups/Alerts bereinigen
- Cluster: statusTag() bekommt t-Parameter; Status-Labels (online/offline/
  joining/leaving/unknown) → cluster.status.* Keys
- Users: 'You'-Tag → t('users.you') / de: 'Ich'
- Backups: 'OK'-Status-Tag → t('backups.okTag')
- Alerts: Kanal-Kind-Labels + alle SMTP-Formular-Labels in t() überführt
- en/de: cluster.status.*, users.you, backups.okTag, alerts.smtp.*,
  alerts.kindWebhook/kindEmail ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:47:28 +02:00

385 lines
13 KiB
TypeScript

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'
import EmptyState from '../../components/EmptyState'
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 quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: Channel; checked: boolean }) => {
await apiClient.put(`/alerts/channels/${id}`, buildPayload({ ...row, active: checked } as ChannelFormValues))
},
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}
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}>
<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)
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>
<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={openChannelCreate}>
{t('alerts.add')}
</Button>
}>
<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={
<Button type="primary" icon={<PlusOutlined />} onClick={openChannelCreate}>
{t('alerts.add')}
</Button>
}
/>
) }} />
</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: (
<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>
)
}