- 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>
385 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|