feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)

Backend:
- Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date)
- NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle)
- System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler)
- Domain-Response-Headers + Rate-Limit (Migration 0024)
- Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out
- apt-Service für Update-Banner (apt-get update + Versionsprüfung)
- Backup-Retry mit exponential backoff (retry_apt 3×)
- publish.sh fail-fast + cleanup-old.sh (max 10 Versionen)

Frontend:
- Audit-Log-Page (/audit) mit Filter + Pagination
- ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+)
- Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix)
- EmptyState-Komponente überall ausgerollt
- SSL: Aggregate-Karte (total/expiring/expired/errors)
- Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now
- NTP: Sync-Status-Karte (chronyc tracking live)
- Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats
- Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN)
- Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler)
- Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention
- Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint
- System-Regeln im Firewall als eigener Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-19 16:18:41 +02:00
parent 3178e25e78
commit 35b7308ce2
82 changed files with 8408 additions and 392 deletions

View File

@@ -12,6 +12,7 @@ import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import EmptyState from '../../components/EmptyState'
const { Text } = Typography
@@ -234,6 +235,11 @@ export default function AlertsPage() {
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
@@ -266,17 +272,25 @@ export default function AlertsPage() {
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 })
}}>
onClick={openChannelCreate}>
{t('alerts.add')}
</Button>
}>
<Table size="small" rowKey="id" loading={channels.isFetching}
dataSource={channels.data ?? []} columns={chanColumns}
pagination={false}
locale={{ emptyText: t('alerts.emptyChannels') }} />
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>
),
},
@@ -288,7 +302,13 @@ export default function AlertsPage() {
<Table size="small" rowKey="id" loading={events.isFetching}
dataSource={events.data ?? []} columns={evColumns}
pagination={{ pageSize: 25 }}
locale={{ emptyText: t('alerts.emptyEvents') }} />
locale={{ emptyText: (
<EmptyState
icon={<BellOutlined />}
title={t('alerts.emptyEventsTitle')}
description={t('alerts.emptyEventsDesc')}
/>
) }} />
</Card>
),
},

View File

@@ -0,0 +1,204 @@
import { useState } from 'react'
import { Button, Card, Col, DatePicker, Form, Input, Row, Space, Tag, Typography } from 'antd'
import { FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import type { Dayjs } from 'dayjs'
import PageHeader from '../../components/PageHeader'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
interface AuditEntry {
id: number
actor: string
action: string
subject?: string | null
detail?: unknown
node_id?: string | null
created_at: string
}
interface SearchParams {
actor?: string
action?: string
subject?: string
since?: Dayjs | null
until?: Dayjs | null
}
interface FormValues {
actor?: string
action?: string
subject?: string
range?: [Dayjs, Dayjs] | null
}
const PAGE_SIZE = 100
async function searchAudit(p: SearchParams, offset: number): Promise<AuditEntry[]> {
const params: Record<string, string> = { limit: String(PAGE_SIZE), offset: String(offset) }
if (p.actor) params.actor = p.actor
if (p.action) params.action = p.action
if (p.subject) params.subject = p.subject
if (p.since) params.since = p.since.toISOString()
if (p.until) params.until = p.until.toISOString()
const r = await apiClient.get('/audit/search', { params })
if (!isEnvelope(r.data)) return []
return (r.data.data as { entries?: AuditEntry[] }).entries ?? []
}
export default function AuditPage() {
const { t } = useTranslation()
const [form] = Form.useForm<FormValues>()
const [filters, setFilters] = useState<SearchParams>({})
const [offset, setOffset] = useState(0)
const { data: entries, isLoading, refetch } = useQuery({
queryKey: ['audit', 'search', filters, offset],
queryFn: () => searchAudit(filters, offset),
})
const onSubmit = (v: FormValues) => {
setOffset(0)
setFilters({
actor: v.actor?.trim() || undefined,
action: v.action?.trim() || undefined,
subject: v.subject?.trim() || undefined,
since: v.range?.[0] ?? null,
until: v.range?.[1] ?? null,
})
}
const onReset = () => {
form.resetFields()
setOffset(0)
setFilters({})
}
const columns: ColumnsType<AuditEntry> = [
{
title: t('audit.col.time'), key: 'created_at', dataIndex: 'created_at', width: 170,
render: (s: string) => (
<Typography.Text style={{ fontSize: 12 }}>
{new Date(s).toLocaleString()}
</Typography.Text>
),
},
{
title: t('audit.col.actor'), key: 'actor', dataIndex: 'actor', width: 200,
render: (s: string) => <code style={{ fontSize: 12 }}>{s}</code>,
},
{
title: t('audit.col.action'), key: 'action', dataIndex: 'action', width: 220,
render: (s: string) => <Tag color="blue" style={{ fontFamily: 'monospace' }}>{s}</Tag>,
},
{
title: t('audit.col.subject'), key: 'subject', dataIndex: 'subject',
render: (s?: string | null) => s ? <code style={{ fontSize: 12 }}>{s}</code> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: t('audit.col.detail'), key: 'detail',
render: (_, row) => {
if (!row.detail) return <Typography.Text type="secondary"></Typography.Text>
const txt = typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail)
if (txt.length <= 80) {
return <Typography.Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{txt}</Typography.Text>
}
return (
<details>
<summary style={{ fontSize: 11, color: '#64748B', cursor: 'pointer' }}>
{t('audit.detailShow')}
</summary>
<pre style={{ fontSize: 11, margin: '4px 0 0 0', maxWidth: 480, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{txt}</pre>
</details>
)
},
},
]
const hasMore = (entries?.length ?? 0) === PAGE_SIZE
return (
<div>
<PageHeader
icon={<FileSearchOutlined />}
title={t('audit.title')}
subtitle={t('audit.intro')}
/>
<Card size="small" style={{ marginBottom: 12 }}>
<Form
form={form}
layout="vertical"
onFinish={onSubmit}
initialValues={{ actor: '', action: '', subject: '', range: null }}
>
<Row gutter={12}>
<Col xs={24} sm={12} md={6}>
<Form.Item label={t('audit.filter.actor')} name="actor">
<Input placeholder="z.B. admin@…" allowClear />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item label={t('audit.filter.action')} name="action">
<Input placeholder="z.B. domain.update" allowClear />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item label={t('audit.filter.subject')} name="subject">
<Input placeholder="z.B. example.com" allowClear />
</Form.Item>
</Col>
<Col xs={24} sm={12} md={6}>
<Form.Item label={t('audit.filter.range')} name="range">
<DatePicker.RangePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Space>
<Button type="primary" htmlType="submit">{t('audit.filter.search')}</Button>
<Button onClick={onReset}>{t('audit.filter.reset')}</Button>
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
{t('common.refresh')}
</Button>
</Space>
</Form>
</Card>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={entries ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<FileSearchOutlined />}
title={t('audit.empty.title')}
description={t('audit.empty.desc')}
/>
}
/>
<Space style={{ marginTop: 12 }}>
<Button
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
{t('audit.page.prev')}
</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('audit.page.showing', { from: offset + 1, to: offset + (entries?.length ?? 0) })}
</Typography.Text>
<Button
disabled={!hasMore}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
{t('audit.page.next')}
</Button>
</Space>
</div>
)
}

View File

@@ -8,6 +8,7 @@ import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -85,12 +86,37 @@ async function listDomains(): Promise<DomainFull[]> {
return (r.data.data as { domains?: DomainFull[] }).domains ?? []
}
interface HAProxyStat { backend: string; server: string; status: string }
async function listHAProxyStats(): Promise<HAProxyStat[]> {
try {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
}
export default function BackendsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
refetchInterval: 15_000,
})
// UP = alle Server UP, DEGRADED = mind. 1 UP + mind. 1 DOWN, DOWN = alle DOWN
const backendLiveStatus = (id: number): 'UP' | 'DEGRADED' | 'DOWN' | null => {
if (!haproxyStats?.length) return null
const servers = haproxyStats.filter(s => s.backend === `eg_backend_${id}`)
if (!servers.length) return null
const upCount = servers.filter(s => s.status === 'UP').length
if (upCount === servers.length) return 'UP'
if (upCount > 0) return 'DEGRADED'
return 'DOWN'
}
// server-counts pro Backend laden wir lazy bei Expansion; in der
// Tabelle reicht ein Hinweis ob 0 / N Server.
@@ -211,6 +237,15 @@ export default function BackendsPage() {
return <Space size={4} wrap>{ds.map(d => <Tag key={d.id} color="blue">{d.name}</Tag>)}</Space>
},
},
{
title: t('backends.liveStatus'), key: 'liveStatus', width: 110,
render: (_, row) => {
const s = backendLiveStatus(row.id)
if (!s) return <Text type="secondary" style={{ fontSize: 12 }}></Text>
const color = s === 'UP' ? 'green' : s === 'DEGRADED' ? 'orange' : 'red'
return <Tag color={color} style={{ margin: 0 }}>{s}</Tag>
},
},
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('common.actions'), key: 'actions',
@@ -235,6 +270,11 @@ export default function BackendsPage() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', websocket: false, active: true })
}
return (
<div>
<PageHeader
@@ -252,13 +292,22 @@ export default function BackendsPage() {
rowExpandable: (record) => !!record.id,
}}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', websocket: false, active: true })
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('backends.addBackend')}
</Button>
}
emptyContent={
<EmptyState
icon={<DatabaseOutlined />}
title={t('backends.emptyTitle')}
description={t('backends.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('backends.addBackend')}
</Button>
}
/>
}
/>
<Modal
title={editing ? t('backends.editBackend') : t('backends.addBackend')}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import {
Alert, Button, Card, Popconfirm, Space, Table, Tag, Tooltip, Typography, message,
Alert, Button, Card, Col, Popconfirm, Row, Space, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
@@ -252,9 +252,68 @@ export default function HistoryTab() {
},
]
// Aggregat-Zähler. Quelle: die bereits geladene Liste — die Backup-
// History wird in der UI sowieso komplett geladen (Pagination ist
// clientseitig). Keine zusätzlichen Endpoint-Calls.
const all = list.data ?? []
const succ = all.filter((b) => b.status === 'success')
const lastSucc = succ[0] // bereits DESC sortiert vom Backend
const totalSize = succ.reduce((acc, b) => acc + (b.size_bytes || 0), 0)
const last24hMs = Date.now() - 86_400_000
const failsLast24h = all.filter((b) =>
b.status === 'failed' && new Date(b.started_at).getTime() >= last24hMs,
).length
const lastSuccAge = lastSucc
? Math.round((Date.now() - new Date(lastSucc.finished_at).getTime()) / 3_600_000) // hours
: null
return (
<div>
{msgCtx}
{all.length > 0 && (
<Row gutter={12} className="mb-16">
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={t('backups.statLastSuccess')}
value={
lastSuccAge == null
? '—'
: lastSuccAge < 24
? t('backups.statHoursAgo', { n: lastSuccAge })
: t('backups.statDaysAgo', { n: Math.round(lastSuccAge / 24) })
}
valueStyle={
lastSuccAge == null ? { color: '#cf1322' }
: lastSuccAge > 48 ? { color: '#d48806' }
: { color: '#0F172A' }
}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title={t('backups.statTotal')} value={succ.length} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title={t('backups.statSize')} value={fmtSize(totalSize)} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={t('backups.statFails24h')}
value={failsLast24h}
valueStyle={failsLast24h > 0 ? { color: '#cf1322' } : undefined}
/>
</Card>
</Col>
</Row>
)}
<Space style={{ marginBottom: 12 }}>
<Tooltip title={t('backups.refreshTooltip')}>
<Button icon={<ReloadOutlined />} onClick={() => list.refetch()}>

View File

@@ -1,7 +1,8 @@
import { Alert, Card, Descriptions, Space, Spin, Table, Tag, Typography } from 'antd'
import { Alert, Button, Card, Descriptions, Input, Modal, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ApartmentOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
@@ -35,6 +36,52 @@ interface ClusterStatus {
updated_at: string
}
interface NodeResources {
load_avg_1: number
load_avg_5: number
load_avg_15: number
mem_used_pct: number
mem_total_kb: number
mem_avail_kb: number
disk_used_pct: number
disk_total_gb: number
disk_free_gb: number
conntrack_count: number
conntrack_max: number
uptime_sec: number
}
interface PeerLoadResult {
node_id: string
fqdn: string
ok: boolean
data?: NodeResources
error?: string
duration_ms: number
}
interface JoinTokenResponse {
token: string
expires_at: string
ca_fingerprint: string
}
interface CertInfo {
common_name: string
not_before: string
not_after: string
days_remaining: number
is_ca: boolean
serial_hex: string
}
interface CertStatus {
has_ca: boolean
has_peer: boolean
ca?: CertInfo
peer?: CertInfo
}
function statusTag(s: HANode['status']) {
switch (s) {
case 'online': return <Tag color="green">online</Tag>
@@ -45,16 +92,30 @@ function statusTag(s: HANode['status']) {
}
}
function lastSeenRelative(iso?: string | null): string {
function lastSeenRelative(iso: string | null | undefined, now: number): string {
if (!iso) return '—'
const ms = Date.now() - new Date(iso).getTime()
const ms = now - new Date(iso).getTime()
if (ms < 0) return '—'
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
return `${Math.round(ms / 3_600_000)}h`
}
// useTickingNow gibt einen `now`-Wert zurück der jede Sekunde
// re-rendert. Damit tickt das Cluster-Last-Seen-Label visuell jede
// Sekunde, statt nur alle 30s beim useQuery-Refetch.
function useTickingNow(intervalMs = 1000): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(t)
}, [intervalMs])
return now
}
export default function ClusterPage() {
const { t } = useTranslation()
const now = useTickingNow()
const { data, isLoading } = useQuery({
queryKey: ['cluster', 'status'],
@@ -65,9 +126,76 @@ export default function ClusterPage() {
refetchInterval: 30_000,
})
// Phase 3.3: per-Node Load via mTLS-Aggregator. Single-node = nur die
// eigene Zeile. Multi-Node = pro Peer eine. refetchInterval bewusst
// langsamer als /cluster/status weil fan-out N×3s Netzwerk-Roundtrips
// bedeuten kann.
const loadQuery = useQuery({
queryKey: ['cluster', 'system-load'],
queryFn: async () => {
const r = await apiClient.get('/cluster/system/load')
const payload = isEnvelope(r.data) ? (r.data.data as { nodes?: PeerLoadResult[] }) : null
return payload?.nodes ?? []
},
refetchInterval: 60_000,
})
const certStatus = useQuery({
queryKey: ['cluster', 'cert-status'],
queryFn: async () => {
const r = await apiClient.get('/cluster/cert-status')
return isEnvelope(r.data) ? (r.data.data as CertStatus) : null
},
refetchInterval: 5 * 60_000,
})
const qc = useQueryClient()
const removePeer = useMutation({
mutationFn: async (id: string) => apiClient.delete(`/cluster/nodes/${id}`),
onSuccess: () => {
message.success(t('cluster.removePeerOk'))
void qc.invalidateQueries({ queryKey: ['cluster'] })
},
onError: (e: Error) => message.error(t('cluster.removePeerFailed') + ': ' + e.message),
})
const renewSelf = useMutation({
mutationFn: async () => {
const r = await apiClient.post('/cluster/renew-self')
return r.data
},
onSuccess: () => {
message.success(t('cluster.certRenewedRestartHint'))
void certStatus.refetch()
},
onError: (e: Error) => {
message.error(t('cluster.certRenewFailed') + ': ' + e.message)
},
})
const [joinTokenOpen, setJoinTokenOpen] = useState(false)
const [joinToken, setJoinToken] = useState<JoinTokenResponse | null>(null)
const generateToken = useMutation({
mutationFn: async () => {
const r = await apiClient.post('/cluster/join-tokens')
return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null
},
onSuccess: (t) => {
setJoinToken(t)
setJoinTokenOpen(true)
},
onError: () => {
message.error(t('cluster.joinTokenFailed'))
},
})
if (isLoading) return <Spin />
if (!data) return null
const primaryFqdn = data.local_node?.fqdn ?? '<primary-fqdn>'
const joinCmd = joinToken
? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}`
: ''
const peerColumns: ColumnsType<HANode> = [
{
title: t('cluster.col.node'), key: 'node',
@@ -103,9 +231,37 @@ export default function ClusterPage() {
{ title: t('cluster.col.version'), dataIndex: 'version', width: 100,
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary"></Text> },
{
title: t('cluster.col.lastSeen'), dataIndex: 'last_seen', width: 100,
render: (v?: string | null) => (
<Text type="secondary" style={{ fontSize: 12 }}>{lastSeenRelative(v)}</Text>
title: t('cluster.col.lastSeen'), dataIndex: 'last_seen', width: 110,
render: (v: string | null | undefined, r: HANode) => {
const rel = lastSeenRelative(v, now)
const tipText = v ? new Date(v).toLocaleString() : t('cluster.col.lastSeen')
const stale = r.status !== 'online'
return (
<Tooltip title={tipText}>
<Text type={stale ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>{rel}</Text>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'actions', width: 110,
render: (_, r) => (
<Popconfirm
title={t('cluster.removePeerConfirmTitle')}
description={t('cluster.removePeerConfirmDesc', { fqdn: r.fqdn })}
okText={t('common.yes')}
cancelText={t('common.no')}
okButtonProps={{ danger: true }}
onConfirm={() => removePeer.mutate(r.id)}
>
<Button
type="text" size="small" danger
icon={<DeleteOutlined />}
loading={removePeer.isPending && removePeer.variables === r.id}
>
{t('cluster.removePeerBtn')}
</Button>
</Popconfirm>
),
},
]
@@ -118,6 +274,13 @@ export default function ClusterPage() {
subtitle={t('cluster.intro', { count: 1 + data.peers.length })}
extra={
<Space>
<Button
icon={<KeyOutlined />}
loading={generateToken.isPending}
onClick={() => generateToken.mutate()}
>
{t('cluster.generateJoinToken')}
</Button>
<Tag color={data.mode === 'cluster' ? 'blue' : 'default'}>
{data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')}
</Tag>
@@ -171,6 +334,13 @@ export default function ClusterPage() {
<Descriptions.Item label={t('cluster.col.version')}>
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.lastSeen')}>
<Tooltip title={data.local_node.last_seen ? new Date(data.local_node.last_seen).toLocaleString() : '—'}>
<Text type={data.local_node.status === 'online' ? 'secondary' : 'danger'} style={{ fontSize: 12 }}>
{lastSeenRelative(data.local_node.last_seen, now)}
</Text>
</Tooltip>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.col.mgmtIp')}>
<Text style={{ fontFamily: 'monospace' }}>
{data.local_node.mgmt_ip || '—'}
@@ -190,6 +360,46 @@ export default function ClusterPage() {
)}
</Card>
{(certStatus.data?.has_ca || certStatus.data?.has_peer) && (
<Card size="small" title={t('cluster.certCardTitle')} className="mb-16"
extra={certStatus.data?.has_ca && (
<Popconfirm
title={t('cluster.renewSelfConfirm')}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={() => renewSelf.mutate()}
>
<Button size="small" loading={renewSelf.isPending}>
{t('cluster.renewSelfBtn')}
</Button>
</Popconfirm>
)}
>
<Descriptions size="small" column={2} bordered>
{certStatus.data.ca && (
<>
<Descriptions.Item label={t('cluster.certCALabel')}>
<Text>{certStatus.data.ca.common_name}</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.certExpiry')}>
<CertExpiry days={certStatus.data.ca.days_remaining} until={certStatus.data.ca.not_after} />
</Descriptions.Item>
</>
)}
{certStatus.data.peer && (
<>
<Descriptions.Item label={t('cluster.certPeerLabel')}>
<Text>{certStatus.data.peer.common_name}</Text>
</Descriptions.Item>
<Descriptions.Item label={t('cluster.certExpiry')}>
<CertExpiry days={certStatus.data.peer.days_remaining} until={certStatus.data.peer.not_after} />
</Descriptions.Item>
</>
)}
</Descriptions>
</Card>
)}
{data.peers.length > 0 && (
<Card size="small" title={t('cluster.peersTitle', { count: data.peers.length })}>
<Table
@@ -201,6 +411,159 @@ export default function ClusterPage() {
/>
</Card>
)}
{/* Per-Node Resources via mTLS-Aggregator (Phase 3.3). Bei
Single-Node 1 Zeile; bei Cluster N. duration_ms zeigt welcher
Peer langsam ist (Netzwerk-Latenz oder Last). */}
<Card
size="small"
title={t('cluster.loadTitle')}
className="mt-16"
loading={loadQuery.isLoading}
>
<Table<PeerLoadResult>
size="small"
rowKey="node_id"
dataSource={loadQuery.data ?? []}
pagination={false}
locale={{ emptyText: t('cluster.loadEmpty') }}
columns={[
{
title: t('cluster.col.node'), key: 'node',
render: (_, r) => (
<Space>
<Text strong>{r.fqdn || r.node_id}</Text>
{!r.ok && <Tag color="red">{r.error || 'error'}</Tag>}
</Space>
),
},
{
title: t('cluster.col.load'), key: 'load', width: 110,
render: (_, r) => r.ok && r.data
? <Text style={{ fontFamily: 'monospace' }}>
{r.data.load_avg_1.toFixed(2)} / {r.data.load_avg_5.toFixed(2)} / {r.data.load_avg_15.toFixed(2)}
</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.mem'), key: 'mem', width: 110,
render: (_, r) => r.ok && r.data
? <Text>{r.data.mem_used_pct.toFixed(0)}%</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.disk'), key: 'disk', width: 110,
render: (_, r) => r.ok && r.data
? <Text>{r.data.disk_used_pct.toFixed(0)}%</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.conntrack'), key: 'ct', width: 130,
render: (_, r) => r.ok && r.data
? <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{r.data.conntrack_count}/{r.data.conntrack_max}
</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.uptime'), key: 'up', width: 100,
render: (_, r) => r.ok && r.data
? <Text type="secondary" style={{ fontSize: 12 }}>{formatUptime(r.data.uptime_sec)}</Text>
: <Text type="secondary"></Text>,
},
{
title: t('cluster.col.fetchMs'), key: 'ms', width: 80,
render: (_, r) => (
<Text type="secondary" style={{ fontSize: 11 }}>{r.duration_ms}ms</Text>
),
},
]}
/>
</Card>
<Modal
title={t('cluster.joinTokenTitle')}
open={joinTokenOpen}
onCancel={() => setJoinTokenOpen(false)}
footer={<Button onClick={() => setJoinTokenOpen(false)}>{t('common.close')}</Button>}
width={720}
>
{joinToken ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={t('cluster.joinTokenOneShot')}
description={t('cluster.joinTokenOneShotDesc', {
expires: new Date(joinToken.expires_at).toLocaleString(),
})}
/>
<Descriptions size="small" column={1} bordered>
<Descriptions.Item label="Token">
<Input.TextArea
value={joinToken.token}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
style={{ fontFamily: 'monospace', fontSize: 11 }}
/>
</Descriptions.Item>
<Descriptions.Item label="CA-Fingerprint">
<Text code>{joinToken.ca_fingerprint}</Text>
</Descriptions.Item>
</Descriptions>
<div>
<Text strong>{t('cluster.joinCmdLabel')}</Text>
<Input.TextArea
value={joinCmd}
readOnly
autoSize={{ minRows: 2, maxRows: 4 }}
style={{ fontFamily: 'monospace', fontSize: 12, marginTop: 6 }}
/>
<Button
icon={<CopyOutlined />}
size="small"
style={{ marginTop: 6 }}
onClick={() => {
void navigator.clipboard.writeText(joinCmd)
message.success(t('common.copied'))
}}
>
{t('common.copy')}
</Button>
</div>
</Space>
) : null}
</Modal>
</div>
)
}
// CertExpiry rendert "<n> Tage" mit Farbcode: rot < 30, orange < 90,
// grün sonst. Tooltip zeigt das absolute NotAfter-Datum.
function CertExpiry({ days, until }: { days: number; until: string }) {
let color: string | undefined
if (days < 0) color = '#cf1322' // already expired
else if (days < 30) color = '#cf1322' // critical
else if (days < 90) color = '#d4651a' // warning
else color = '#52c41a' // healthy
const label = days < 0 ? `abgelaufen vor ${-days} Tagen` : `${days} Tage`
return (
<Tooltip title={new Date(until).toLocaleString()}>
<Tag color={color === '#52c41a' ? 'green' : color === '#d4651a' ? 'orange' : 'red'}>
{label}
</Tag>
</Tooltip>
)
}
// formatUptime liefert "Xd Yh" oder "Xh Ym" oder "Xm" — kompakter als
// die Sekunden-Zahl.
function formatUptime(sec: number): string {
if (!sec || sec < 0) return '—'
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
if (d > 0) return `${d}d ${h}h`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}

View File

@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -152,6 +153,11 @@ function ZonesTab() {
},
]
const openZoneCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
}
return (
<>
<DataTable
@@ -160,13 +166,22 @@ function ZonesTab() {
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openZoneCreate}>
{t('dns.zone.add')}
</Button>
}
emptyContent={
<EmptyState
icon={<GlobalOutlined />}
title={t('dns.zone.emptyTitle')}
description={t('dns.zone.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openZoneCreate}>
{t('dns.zone.add')}
</Button>
}
/>
}
/>
<Modal
@@ -273,6 +288,11 @@ function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
},
]
const openRecordCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord)
}
return (
<Drawer
open={open}
@@ -292,13 +312,22 @@ function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord)
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openRecordCreate}>
{t('dns.record.add')}
</Button>
}
emptyContent={
<EmptyState
icon={<NodeIndexOutlined />}
title={t('dns.record.emptyTitle')}
description={t('dns.record.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openRecordCreate}>
{t('dns.record.add')}
</Button>
}
/>
}
/>
<Modal

View File

@@ -1,9 +1,10 @@
import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typography } from 'antd'
import {
ApartmentOutlined, ApiOutlined, BranchesOutlined, ClusterOutlined,
ApartmentOutlined, ApiOutlined, BellOutlined, BranchesOutlined, ClusterOutlined,
DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined,
SafetyCertificateOutlined, ThunderboltOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -11,6 +12,7 @@ import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import StatusDot from '../../components/StatusDot'
import UpdateBanner from '../../components/UpdateBanner'
const { Text } = Typography
@@ -153,6 +155,15 @@ function relativeFromIso(iso: string): string {
// ── Page ──────────────────────────────────────────────────────
interface AlertEvent {
id: number
kind: string
severity: 'info' | 'warning' | 'error' | 'critical'
subject: string
message: string
fired_at: string
}
export default function DashboardPage() {
const { t } = useTranslation()
@@ -161,6 +172,12 @@ export default function DashboardPage() {
queryFn: () => fetchOne<{ status: string; version: string }>('/system/health'),
refetchInterval: 30_000,
})
const recentAlerts = useQuery({
queryKey: ['alerts', 'events', 'recent'],
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
refetchInterval: 60_000,
})
const services = useQuery({
queryKey: ['system', 'services'],
queryFn: () => fetchList<ServiceStatus>('/system/services', 'services'),
@@ -186,6 +203,34 @@ export default function DashboardPage() {
const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList<FwZone>('/firewall/zones', 'zones') })
const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList<TLSCert>('/tls-certs', 'tls_certs') })
const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList<ClusterNode>('/cluster/nodes', 'nodes') })
// Zusätzlich /cluster/status für die Health-Ampel: liefert mode +
// health + drift_found. Refresh-Intervall etwas länger (30s) als die
// anderen Dashboard-Queries — die meisten Werte ändern sich selten.
const clusterStatus = useQuery({
queryKey: ['cluster', 'status'],
queryFn: () => fetchOne<{
mode: 'single-node' | 'cluster'
health: 'ok' | 'degraded' | 'split-brain'
drift_found: boolean
}>('/cluster/status'),
refetchInterval: 30_000,
})
// License-Status für PageHeader-Tag — frische Boxen sehen sofort
// wieviel Trial-Zeit übrig ist. Kein Spam: Anzeige nur wenn
// payload da ist; bei Errors fall silent (Lizenz-Page bleibt
// die Autoritäts-Quelle).
const license = useQuery({
queryKey: ['license', 'status'],
queryFn: () => fetchOne<{
status: string
type?: string
valid?: boolean
valid_until?: string
expires_at?: string
license_key?: string
}>('/license/status'),
refetchInterval: 5 * 60_000,
})
const wgIfaces = useQuery({ queryKey: ['wg', 'interfaces'], queryFn: () => fetchList<WGIface>('/wireguard/interfaces', 'interfaces') })
const wgStatus = useQuery({
queryKey: ['wg', 'status'],
@@ -219,12 +264,44 @@ export default function DashboardPage() {
subtitle={t('dashboard.welcomeHint')}
extra={
<Space>
{/* Compact-Variante: prominenter „Auf Updates prüfen"-Button
im Dashboard-Header (Pattern 1:1 aus mail-gateway
Dashboard/v2/index.tsx). Bypasst den Server-seitigen
5-min-apt-update-Throttle via ?force=1, sodass der
Operator nach einem Publish nicht aufs 30s-Polling
warten muss. Der globale Banner in AppLayout zeigt
das Ergebnis dann sofort an. */}
<UpdateBanner compact />
{license.data && <LicenseChip data={license.data} />}
<Tag color="blue">v{health.data?.version ?? '—'}</Tag>
<StatusDot active={health.data?.status === 'ok'} />
</Space>
}
/>
{/* ── Onboarding-Hinweis für frische Boxen ──────────
Erscheint nur wenn 0 Domains UND 0 Backends — verschwindet
sobald irgendwas konfiguriert ist. Drei klickbare Quick-Links
zu den nächsten typischen Setup-Schritten. */}
{(domains.data?.length ?? 0) === 0 && (backends.data?.length ?? 0) === 0 && (
<Alert
type="info"
showIcon
className="mb-12"
message={t('dashboard.onboardingTitle')}
description={
<Space direction="vertical" size={4}>
<Text>{t('dashboard.onboardingIntro')}</Text>
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
<li><Link to="/backends">{t('dashboard.onboardingStep1')}</Link></li>
<li><Link to="/domains">{t('dashboard.onboardingStep2')}</Link></li>
<li><Link to="/ssl">{t('dashboard.onboardingStep3')}</Link></li>
</ol>
</Space>
}
/>
)}
{/* ── KPI tiles (compact strip) ──────────────────── */}
<Row gutter={[12, 12]} className="mb-12">
<KPI icon={<GlobalOutlined />} label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} />
@@ -240,6 +317,39 @@ export default function DashboardPage() {
<ResourcesCard r={resources.data} />
</Row>
{/* ── Recent Alerts ──────────────────────────────────
Card erscheint nur wenn überhaupt Events da sind, sonst macht
sie auf einer frischen Box visuelles Rauschen. Link zur
vollständigen Alerts-Seite für Filter + Channel-Config. */}
{(recentAlerts.data?.length ?? 0) > 0 && (
<Card
size="small"
className="mb-12"
title={<><BellOutlined /> {t('dashboard.alertsCard.title')}</>}
extra={<Link to="/alerts">{t('dashboard.alertsCard.viewAll')}</Link>}
>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{(recentAlerts.data ?? []).map(e => (
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color={
e.severity === 'critical' ? 'red'
: e.severity === 'error' ? 'red'
: e.severity === 'warning' ? 'orange'
: 'blue'
}>{e.severity}</Tag>
<Text strong style={{ flex: '0 0 auto' }}>{e.subject}</Text>
<Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto' }}>
{new Date(e.fired_at).toLocaleString()}
</Text>
<Text type="secondary" ellipsis style={{ flex: '1 1 auto', fontSize: 12 }}>
{e.message}
</Text>
</div>
))}
</Space>
</Card>
)}
{/* ── Service-health-grid ─────────────────────────── */}
<Card size="small" title={<><DashboardOutlined /> {t('dashboard.servicesCard.title')}</>} className="mb-12">
<Row gutter={[8, 8]}>
@@ -341,8 +451,33 @@ export default function DashboardPage() {
{/* ── Cluster ─────────────────────────────────────── */}
<Col xs={24} lg={12}>
<Card size="small" title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>} className="h-100">
<Card
size="small"
title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>}
className="h-100"
extra={clusterStatus.data && (
<Space size={4}>
<Tag color={clusterStatus.data.mode === 'cluster' ? 'blue' : 'default'}>
{clusterStatus.data.mode === 'cluster'
? t('dashboard.clusterCard.modeCluster')
: t('dashboard.clusterCard.modeSingle')}
</Tag>
<Tag color={
clusterStatus.data.health === 'ok' ? 'green'
: clusterStatus.data.health === 'degraded' ? 'orange'
: 'red'
}>
{t(`dashboard.clusterCard.health.${clusterStatus.data.health}`)}
</Tag>
</Space>
)}
>
<Statistic title={t('dashboard.clusterCard.nodes')} value={(cluster.data ?? []).length} />
{clusterStatus.data?.drift_found && (
<Tag color="red" style={{ marginTop: 8 }}>
{t('dashboard.clusterCard.drift')}
</Tag>
)}
<Space direction="vertical" style={{ marginTop: 6, width: '100%' }} size={2}>
{(cluster.data ?? []).map(n => (
<div key={n.id} style={{ fontSize: 12, color: '#334155' }}>
@@ -471,3 +606,36 @@ function ResourcesCard({ r }: { r?: Resources | null }) {
</Col>
)
}
// LicenseChip rendert die License-Info als kompaktes Tag im PageHeader.
// Farb-Logik:
// * Trial < 7 Tage: rot (Eskalation)
// * Trial 7-14 Tage: orange (Warnung)
// * Trial > 14 Tage: blau (informativ)
// * Aktive Lizenz (kein Trial): grün
// * Expired/Invalid: rot
// Bei unklarem status → kein Tag (silent fallback, /license-Page hat Detail).
function LicenseChip({ data }: { data: {
status: string
type?: string
valid?: boolean
valid_until?: string
expires_at?: string
license_key?: string
}}) {
const exp = data.valid_until ?? data.expires_at
const days = exp ? Math.ceil((new Date(exp).getTime() - Date.now()) / 86_400_000) : null
const isTrial = data.type === 'trial' || (!data.license_key && data.status === 'active')
if (data.status === 'expired' || data.status === 'invalid' || data.valid === false) {
return <Tag color="red">{data.status}</Tag>
}
if (isTrial) {
if (days != null && days <= 7) return <Tag color="red">Trial · {days}d</Tag>
if (days != null && days <= 14) return <Tag color="orange">Trial · {days}d</Tag>
return <Tag color="blue">{days != null ? `Trial · ${days}d` : 'Trial'}</Tag>
}
if (data.status === 'active') {
return <Tag color="green">License OK</Tag>
}
return null
}

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import { Button, Form, Input, Modal, Select, Switch, Tag, message } from 'antd'
import { Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
import { DeleteOutlined, EditOutlined, GlobalOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -18,6 +19,14 @@ interface Domain {
primary_backend_id?: number | null
http_to_https: boolean
hsts_enabled: boolean
hsts_max_age: number
hsts_subdomains: boolean
hsts_preload: boolean
maintenance_mode: boolean
maintenance_message?: string | null
www_redirect: '' | 'to-naked' | 'to-www'
rate_limit_rps: number
max_body_kb: number
notes?: string
created_at: string
updated_at: string
@@ -28,10 +37,26 @@ interface DomainFormValues {
active: boolean
http_to_https: boolean
hsts_enabled: boolean
hsts_max_age: number
hsts_subdomains: boolean
hsts_preload: boolean
maintenance_mode: boolean
maintenance_message?: string
www_redirect: '' | 'to-naked' | 'to-www'
rate_limit_rps: number
max_body_kb: number
primary_backend_id?: number | null
notes?: string
}
interface ResponseHeader {
id: number
domain_id: number
name: string
value: string
position: number
}
async function listDomains(): Promise<Domain[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
@@ -52,6 +77,26 @@ async function listBackends(): Promise<BackendLite[]> {
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
}
interface TLSCertLite {
domain: string
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
not_after?: string | null
}
async function listCerts(): Promise<TLSCertLite[]> {
const r = await apiClient.get('/tls-certs')
if (!isEnvelope(r.data)) return []
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
}
interface HAProxyStat { backend: string; server: string; status: string }
async function listHAProxyStats(): Promise<HAProxyStat[]> {
try {
const r = await apiClient.get('/haproxy/stats')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
} catch { return [] }
}
export default function DomainsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
@@ -61,11 +106,62 @@ export default function DomainsPage() {
queryFn: listDomains,
})
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
// TLS-Certs nebenher laden, damit wir pro Domain den Cert-Status
// (vorhanden / gültig / ablaufend / fehlt) als Spalte zeigen können.
// Operator sieht so auf einen Blick welche Domains noch self-signed
// sind und welche bereits ein gültiges ACME-Cert haben.
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: haproxyStats } = useQuery({
queryKey: ['haproxy', 'stats'],
queryFn: listHAProxyStats,
refetchInterval: 15_000,
})
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
// Gibt 'UP', 'DOWN' oder null zurück. HAProxy-Backend heißt eg_backend_<id>.
// Wir aggregieren alle Server: wenn mind. einer UP → UP, sonst DOWN.
const backendHealth = (id?: number | null): 'UP' | 'DOWN' | null => {
if (!id || !haproxyStats?.length) return null
const name = `eg_backend_${id}`
const servers = haproxyStats.filter(s => s.backend === name)
if (!servers.length) return null
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
}
const [editing, setEditing] = useState<Domain | null>(null)
const [creating, setCreating] = useState(false)
const [headersFor, setHeadersFor] = useState<Domain | null>(null)
const [quickBackendOpen, setQuickBackendOpen] = useState(false)
const [form] = Form.useForm<DomainFormValues>()
const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>()
const quickCreateBackend = useMutation({
mutationFn: async (v: { name: string; scheme: 'http' | 'https'; address: string; port: number }) => {
// 1) Backend anlegen
const bRes = await apiClient.post('/backends', {
name: v.name, scheme: v.scheme, lb_algorithm: 'roundrobin',
websocket: false, active: true,
})
const bId = (bRes.data?.data as { id?: number })?.id
if (!bId) throw new Error('backend id missing in response')
// 2) Ersten Server reinhängen
await apiClient.post(`/backends/${bId}/servers`, {
backend_id: bId, name: v.address.replace(/[^a-zA-Z0-9-]/g, '-'),
address: v.address, port: v.port, weight: 100, active: true,
})
return bId
},
onSuccess: (bId) => {
message.success(t('domains.quickBackendCreated'))
void qc.invalidateQueries({ queryKey: ['backends'] })
// Frisch erstellten Backend automatisch im Domain-Form selektieren.
form.setFieldsValue({ primary_backend_id: bId })
setQuickBackendOpen(false)
quickBackendForm.resetFields()
},
onError: (e: Error) => message.error(t('domains.quickBackendFailed') + ': ' + e.message),
})
const create = useMutation({
mutationFn: async (v: DomainFormValues) => {
@@ -109,36 +205,112 @@ export default function DomainsPage() {
render: (id?: number | null) => {
if (!id) return <Tag>{t('domains.noBackend')}</Tag>
const b = backendById(id)
return b
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
: <Tag color="orange">#{id}</Tag>
const health = backendHealth(id)
return (
<Space size={4}>
{b
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
: <Tag color="orange">#{id}</Tag>}
{health === 'UP' && <Tag color="green" style={{ margin: 0 }}>UP</Tag>}
{health === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>DOWN</Tag>}
</Space>
)
},
},
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => <StatusDot active={v} activeLabel="HTTPS" inactiveLabel="HTTP" /> },
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
{
title: t('domains.tlsCert'),
key: 'tlsCert',
width: 120,
render: (_, row) => {
const cert = certByDomain.get(row.name)
if (!cert) {
return (
<Tooltip title={t('domains.tlsCertNoneHint')}>
<Tag color="default">{t('domains.tlsCertNone')}</Tag>
</Tooltip>
)
}
if (cert.status === 'expired') {
return <Tag color="red">{t('domains.tlsCertExpired')}</Tag>
}
if (cert.status === 'error') {
return <Tag color="red">{t('domains.tlsCertError')}</Tag>
}
// Days remaining
const days = cert.not_after
? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000)
: null
if (days != null && days < 30) {
return (
<Tooltip title={cert.not_after}>
<Tag color="orange">{t('domains.tlsCertExpiring', { days })}</Tag>
</Tooltip>
)
}
return (
<Tooltip title={cert.not_after}>
<Tag color="green">{t('domains.tlsCertValid')}</Tag>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
active: row.active,
http_to_https: row.http_to_https,
hsts_enabled: row.hsts_enabled,
primary_backend_id: row.primary_backend_id ?? null,
notes: row.notes ?? '',
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
/>
<Space size={4}>
<Button size="small" onClick={() => setHeadersFor(row)}>
{t('domains.headersBtn')}
</Button>
<ActionButtons
onEdit={() => {
setEditing(row)
form.setFieldsValue({
name: row.name,
active: row.active,
http_to_https: row.http_to_https,
hsts_enabled: row.hsts_enabled,
hsts_max_age: row.hsts_max_age || 31536000,
hsts_subdomains: row.hsts_subdomains,
hsts_preload: row.hsts_preload,
maintenance_mode: row.maintenance_mode,
maintenance_message: row.maintenance_message ?? '',
www_redirect: row.www_redirect ?? '',
rate_limit_rps: row.rate_limit_rps ?? 0,
max_body_kb: row.max_body_kb ?? 0,
primary_backend_id: row.primary_backend_id ?? null,
notes: row.notes ?? '',
})
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
/>
</Space>
),
},
]
// openCreate: reused von extraActions-Button und EmptyState-Action.
// Setzt die Defaults für die Create-Modal-Form.
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
active: true,
http_to_https: true,
hsts_enabled: false,
hsts_max_age: 31536000,
hsts_subdomains: false,
hsts_preload: false,
maintenance_mode: false,
maintenance_message: '',
www_redirect: '',
rate_limit_rps: 0,
max_body_kb: 0,
})
}
return (
<div>
<PageHeader
@@ -152,13 +324,22 @@ export default function DomainsPage() {
dataSource={data ?? []}
columns={columns}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('domains.addDomain')}
</Button>
}
emptyContent={
<EmptyState
icon={<GlobalOutlined />}
title={t('domains.emptyTitle')}
description={t('domains.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('domains.addDomain')}
</Button>
}
/>
}
/>
<Modal
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
@@ -183,16 +364,24 @@ export default function DomainsPage() {
name="primary_backend_id"
extra={t('domains.primaryBackendHint')}
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={t('domains.selectBackend')}
options={(backends ?? []).filter(b => b.active).map(b => ({
value: b.id,
label: `${b.name} (${b.address}:${b.port})`,
}))}
/>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="primary_backend_id" noStyle>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={t('domains.selectBackend')}
options={(backends ?? []).filter(b => b.active).map(b => ({
value: b.id,
label: `${b.name} (${b.address}:${b.port})`,
}))}
style={{ width: '100%' }}
/>
</Form.Item>
<Button onClick={() => setQuickBackendOpen(true)} title={t('domains.quickBackendBtnHint')}>
{t('domains.quickBackendBtn')}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
<Switch />
@@ -200,14 +389,309 @@ export default function DomainsPage() {
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
<Switch />
</Form.Item>
<Divider plain>
<Typography.Text type="secondary">{t('domains.settingsSection')}</Typography.Text>
</Divider>
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.hsts_enabled !== curr.hsts_enabled}
>
{({ getFieldValue }) => getFieldValue('hsts_enabled') ? (
<>
<Form.Item
label={t('domains.hstsMaxAge')}
name="hsts_max_age"
extra={t('domains.hstsMaxAgeHint')}
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
>
<InputNumber min={0} step={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
label={t('domains.hstsSubdomains')}
name="hsts_subdomains"
valuePropName="checked"
extra={t('domains.hstsSubdomainsHint')}
>
<Switch />
</Form.Item>
<Form.Item
label={t('domains.hstsPreload')}
name="hsts_preload"
valuePropName="checked"
extra={t('domains.hstsPreloadHint')}
>
<Switch />
</Form.Item>
</>
) : null}
</Form.Item>
<Form.Item
label={t('domains.wwwRedirect')}
name="www_redirect"
extra={t('domains.wwwRedirectHint')}
>
<Select
options={[
{ value: '', label: t('domains.wwwRedirectNone') },
{ value: 'to-naked', label: t('domains.wwwRedirectToNaked') },
{ value: 'to-www', label: t('domains.wwwRedirectToWWW') },
]}
/>
</Form.Item>
<Form.Item
label={t('domains.maintenance')}
name="maintenance_mode"
valuePropName="checked"
extra={t('domains.maintenanceHint')}
>
<Switch />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) => prev.maintenance_mode !== curr.maintenance_mode}
>
{({ getFieldValue }) => getFieldValue('maintenance_mode') ? (
<Form.Item label={t('domains.maintenanceMessage')} name="maintenance_message">
<Input.TextArea
rows={2}
placeholder={t('domains.maintenanceMessagePlaceholder')}
maxLength={300}
/>
</Form.Item>
) : null}
</Form.Item>
<Form.Item
label={t('domains.rateLimit')}
name="rate_limit_rps"
extra={t('domains.rateLimitHint')}
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
>
<InputNumber min={0} step={10} style={{ width: '100%' }} addonAfter="req/s" />
</Form.Item>
<Form.Item
label={t('domains.maxBody')}
name="max_body_kb"
extra={t('domains.maxBodyHint')}
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
>
<InputNumber min={0} step={64} style={{ width: '100%' }} addonAfter="KiB" />
</Form.Item>
<Form.Item label={t('domains.notes')} name="notes">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{headersFor && (
<HeadersModal
domain={headersFor}
onClose={() => setHeadersFor(null)}
/>
)}
{/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain-
Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends-
Seite öffnen → Backend anlegen → Server anlegen → zurück). */}
<Modal
title={t('domains.quickBackendTitle')}
open={quickBackendOpen}
onCancel={() => { setQuickBackendOpen(false); quickBackendForm.resetFields() }}
onOk={() => { void quickBackendForm.submit() }}
confirmLoading={quickCreateBackend.isPending}
width={520}
>
<Form
form={quickBackendForm}
layout="vertical"
initialValues={{ scheme: 'http', port: 80 }}
onFinish={(v) => quickCreateBackend.mutate(v)}
>
<Form.Item label={t('domains.quickBackendName')} name="name" rules={[{ required: true }]}>
<Input placeholder="app1" />
</Form.Item>
<Form.Item label={t('domains.quickBackendScheme')} name="scheme">
<Select options={[
{ value: 'http', label: 'http' },
{ value: 'https', label: 'https' },
]} />
</Form.Item>
<Form.Item label={t('domains.quickBackendAddress')} name="address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.10" />
</Form.Item>
<Form.Item label={t('domains.quickBackendPort')} name="port" rules={[{ required: true, type: 'number', min: 1, max: 65535 }]}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
// ── Response-Headers Modal ────────────────────────────────────────────
//
// Eigenes Modal weil Headers eine 1:n-Relation sind die nicht in das
// Domain-Form passt. Lädt /domains/:id/headers, erlaubt Inline-Add via
// kleinem Sub-Form, Inline-Edit per Modal pro Row und Delete.
interface HeadersModalProps {
domain: Domain
onClose: () => void
}
async function listHeaders(domainID: number): Promise<ResponseHeader[]> {
const r = await apiClient.get(`/domains/${domainID}/headers`)
if (!isEnvelope(r.data)) return []
return (r.data.data as { headers?: ResponseHeader[] }).headers ?? []
}
function HeadersModal({ domain, onClose }: HeadersModalProps) {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isFetching } = useQuery({
queryKey: ['domain-headers', domain.id],
queryFn: () => listHeaders(domain.id),
})
const [editing, setEditing] = useState<ResponseHeader | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [hForm] = Form.useForm<{ name: string; value: string; position: number }>()
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['domain-headers', domain.id] })
}
const create = useMutation({
mutationFn: async (v: { name: string; value: string; position: number }) =>
apiClient.post(`/domains/${domain.id}/headers`, v),
onSuccess: () => {
message.success(t('common.save'))
setAddOpen(false); hForm.resetFields()
invalidate()
},
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) =>
apiClient.put(`/domains/${domain.id}/headers/${id}`, v),
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); hForm.resetFields()
invalidate()
},
})
const del = useMutation({
mutationFn: async (id: number) =>
apiClient.delete(`/domains/${domain.id}/headers/${id}`),
onSuccess: invalidate,
})
const columns: ColumnsType<ResponseHeader> = [
{ title: t('domains.headerName'), dataIndex: 'name', key: 'name' },
{ title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true },
{
title: t('common.actions'), key: 'actions', width: 100,
render: (_, row) => (
<Space size={4}>
<Button
type="text" size="small" icon={<EditOutlined />}
onClick={() => {
setEditing(row)
hForm.setFieldsValue({ name: row.name, value: row.value, position: row.position })
}}
/>
<Popconfirm
title={t('domains.headerDeleteConfirm', { name: row.name })}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => del.mutate(row.id)}
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<>
<Modal
open
title={t('domains.headersTitle', { name: domain.name })}
onCancel={onClose}
footer={
<Space>
<Button icon={<ReloadOutlined />} onClick={invalidate}>{t('common.refresh')}</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setAddOpen(true); hForm.resetFields()
hForm.setFieldsValue({ name: '', value: '', position: (data?.length ?? 0) })
}}>
{t('domains.addHeader')}
</Button>
<Button onClick={onClose}>{t('common.close')}</Button>
</Space>
}
width={720}
>
<Typography.Paragraph type="secondary">
{t('domains.headersHint')}
</Typography.Paragraph>
<Table
rowKey="id"
size="small"
loading={isFetching}
dataSource={data ?? []}
columns={columns}
pagination={false}
locale={{ emptyText: t('domains.headersEmpty') }}
/>
</Modal>
<Modal
open={addOpen || editing !== null}
title={editing ? t('domains.editHeader') : t('domains.addHeader')}
onCancel={() => { setAddOpen(false); setEditing(null); hForm.resetFields() }}
onOk={() => { void hForm.submit() }}
confirmLoading={create.isPending || update.isPending}
>
<Form
form={hForm}
layout="vertical"
onFinish={(v) => {
if (editing) update.mutate({ id: editing.id, v })
else create.mutate(v)
}}
>
<Form.Item
label={t('domains.headerName')}
name="name"
extra={t('domains.headerNameHint')}
rules={[
{ required: true },
{ pattern: /^[A-Za-z0-9-]+$/, message: t('domains.headerNamePattern') },
]}
>
<Input placeholder="X-Frame-Options" />
</Form.Item>
<Form.Item
label={t('domains.headerValue')}
name="value"
rules={[{ required: true }]}
>
<Input.TextArea rows={2} placeholder="DENY" />
</Form.Item>
<Form.Item label={t('domains.headerPosition')} name="position" initialValue={0}>
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</>
)
}

View File

@@ -3,7 +3,10 @@ import { Button, Form, Input, Modal, Popconfirm, Select, Space, Tag, message } f
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { GroupOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
import type { AddressGroup, AddressObject } from './types'
@@ -84,15 +87,30 @@ export default function AddressGroupsTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ member_ids: [] })
}
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ member_ids: [] })
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.ag.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={groups ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={groups ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<GroupOutlined />}
title={t('fw.ag.emptyTitle')}
description={t('fw.ag.emptyDesc')}
action={<Button type="primary" onClick={openCreate}>{t('fw.ag.add')}</Button>}
/>
}
/>
<Modal
title={editing ? t('fw.ag.edit') : t('fw.ag.add')}
open={editing !== null || creating}

View File

@@ -3,7 +3,10 @@ import { Button, Form, Input, Modal, Popconfirm, Select, Space, Tag, message } f
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { EnvironmentOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
import type { AddressObject } from './types'
@@ -77,15 +80,32 @@ export default function AddressObjectsTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'host' })
}
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'host' })
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.ao.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<EnvironmentOutlined />}
title={t('fw.ao.emptyTitle')}
description={t('fw.ao.emptyDesc')}
action={
<Button type="primary" onClick={openCreate}>{t('fw.ao.add')}</Button>
}
/>
}
/>
<Modal
title={editing ? t('fw.ao.edit') : t('fw.ao.add')}
open={editing !== null || creating}

View File

@@ -3,7 +3,10 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Swi
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { BranchesOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
import type { FwZone, NATRule } from './types'
@@ -132,15 +135,30 @@ export default function NATRulesTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' })
}
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' })
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.nat.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<BranchesOutlined />}
title={t('fw.nat.emptyTitle')}
description={t('fw.nat.emptyDesc')}
action={<Button type="primary" onClick={openCreate}>{t('fw.nat.add')}</Button>}
/>
}
/>
<Modal
title={editing ? t('fw.nat.edit') : t('fw.nat.add')}
open={editing !== null || creating}

View File

@@ -3,8 +3,10 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Swi
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { FireOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import SystemRulesCard from './SystemRules'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
import type { AddressGroup, AddressObject, FwRule, FwService, FwZone, ServiceGroup, Zone } from './types'
@@ -197,20 +199,34 @@ export default function RulesTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
priority: 100, enabled: true, action: 'accept', log: false,
src_zone: 'any', dst_zone: 'any',
src_kind: 'any', dst_kind: 'any', service_kind: 'any',
})
}
return (
<>
<SystemRulesCard />
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
priority: 100, enabled: true, action: 'accept', log: false,
src_zone: 'any', dst_zone: 'any',
src_kind: 'any', dst_kind: 'any', service_kind: 'any',
})
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.rule.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={rules ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={rules ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<FireOutlined />}
title={t('fw.rule.emptyTitle')}
description={t('fw.rule.emptyDesc')}
action={<Button type="primary" onClick={openCreate}>{t('fw.rule.add')}</Button>}
/>
}
/>
<Modal
title={editing ? t('fw.rule.edit') : t('fw.rule.add')}
open={editing !== null || creating}

View File

@@ -3,7 +3,10 @@ import { Button, Form, Input, Modal, Popconfirm, Select, Space, Tag, message } f
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { GroupOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
import type { FwService, ServiceGroup } from './types'
@@ -84,15 +87,30 @@ export default function ServiceGroupsTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ member_ids: [] })
}
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ member_ids: [] })
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.sg.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={groups ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={groups ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<GroupOutlined />}
title={t('fw.sg.emptyTitle')}
description={t('fw.sg.emptyDesc')}
action={<Button type="primary" onClick={openCreate}>{t('fw.sg.add')}</Button>}
/>
}
/>
<Modal
title={editing ? t('fw.sg.edit') : t('fw.sg.add')}
open={editing !== null || creating}

View File

@@ -3,7 +3,10 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Tag
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApiOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
import type { FwService } from './types'
@@ -88,15 +91,30 @@ export default function ServicesTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ proto: 'tcp' })
}
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ proto: 'tcp' })
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.svc.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<ApiOutlined />}
title={t('fw.svc.emptyTitle')}
description={t('fw.svc.emptyDesc')}
action={<Button type="primary" onClick={openCreate}>{t('fw.svc.add')}</Button>}
/>
}
/>
<Modal
title={editing ? t('fw.svc.edit') : t('fw.svc.add')}
open={editing !== null || creating}

View File

@@ -4,8 +4,11 @@ import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { ApartmentOutlined } from '@ant-design/icons'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import type { FwZone } from './types'
interface FormValues {
@@ -77,14 +80,29 @@ export default function ZonesTab() {
},
]
const openCreate = () => { setCreating(true); form.resetFields() }
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
}}>
<Button type="primary" className="mb-16" onClick={openCreate}>
{t('fw.zone.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<ApartmentOutlined />}
title={t('fw.zone.emptyTitle')}
description={t('fw.zone.emptyDesc')}
action={
<Button type="primary" onClick={openCreate}>{t('fw.zone.add')}</Button>
}
/>
}
/>
<Modal
title={editing ? t('fw.zone.edit') : t('fw.zone.add')}

View File

@@ -9,6 +9,7 @@ import ServicesTab from './Services'
import ServiceGroupsTab from './ServiceGroups'
import RulesTab from './Rules'
import NATRulesTab from './NATRules'
import SystemRulesCard from './SystemRules'
import ZonesTab from './Zones'
export default function FirewallPage() {
@@ -22,6 +23,7 @@ export default function FirewallPage() {
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },
{ key: 'services', label: t('fw.tabs.services'), children: <ServicesTab /> },
{ key: 'svcGrp', label: t('fw.tabs.svcGrp'), children: <ServiceGroupsTab /> },
{ key: 'system', label: t('fw.tabs.system'), children: <SystemRulesCard /> },
]
return (

View File

@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -122,6 +123,11 @@ export default function ForwardProxyPage() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' })
}
return (
<div>
<PageHeader
@@ -144,13 +150,22 @@ export default function ForwardProxyPage() {
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' })
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('fwd.add')}
</Button>
}
emptyContent={
<EmptyState
icon={<CloudServerOutlined />}
title={t('fwd.emptyTitle')}
description={t('fwd.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('fwd.add')}
</Button>
}
/>
}
/>
<Modal

View File

@@ -3,6 +3,7 @@ import { Button, Card, Form, Input, InputNumber, Modal, Select, Switch, Tag, Typ
import { NodeIndexOutlined, PlusOutlined } from '@ant-design/icons'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import EmptyState from '../../components/EmptyState'
import StatusDot from '../../components/StatusDot'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -159,6 +160,11 @@ export default function IPAddressesPage() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
}
return (
<div>
<PageHeader
@@ -193,13 +199,22 @@ export default function IPAddressesPage() {
dataSource={ips ?? []}
columns={columns}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('ips.addAddress')}
</Button>
}
emptyContent={
<EmptyState
icon={<NodeIndexOutlined />}
title={t('ips.emptyTitle')}
description={t('ips.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('ips.addAddress')}
</Button>
}
/>
}
/>
<Modal
title={editing ? t('ips.editAddress') : t('ips.addAddress')}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button, Card, DatePicker, Input, Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
} from 'antd'
@@ -72,18 +72,52 @@ function toCSV(rows: Entry[]): string {
return lines.join('\n')
}
// localStorage-Key für die Filter-Persistenz. Filter werden so über
// Reloads + Tab-Wechsel hinweg gehalten — sonst muss der Operator nach
// jedem F5 source/level/grep neu eintippen.
const LOGS_FILTERS_KEY = 'edgeguard.logs.filters.v1'
// Filter aus localStorage lesen + sicher dekodieren. range wird als
// String[2] (ISO) serialisiert; beim Lesen rekonstruieren wir es als
// String — die DatePicker-Range erwartet Dayjs aber range-Restoration
// vereinfachen wir hier zu null, weil Datumsbereiche meist nicht über
// Sessions hinweg interessant sind (logs sind zeitnah).
function loadStoredFilters(): Filters {
const empty: Filters = { sources: [], levels: [], range: null, grep: '', limit: 200 }
try {
const raw = localStorage.getItem(LOGS_FILTERS_KEY)
if (!raw) return empty
const v = JSON.parse(raw) as Partial<Filters>
return {
sources: Array.isArray(v.sources) ? v.sources : [],
levels: Array.isArray(v.levels) ? v.levels : [],
range: null, // Range nicht persistieren (Logs sind „jetzt"-relevant)
grep: typeof v.grep === 'string' ? v.grep : '',
limit: typeof v.limit === 'number' && v.limit > 0 ? v.limit : 200,
}
} catch {
return empty
}
}
export default function LogsPage() {
const { t } = useTranslation()
const [filters, setFilters] = useState<Filters>({
sources: [], // [] = alle
levels: [], // [] = alle
range: null,
grep: '',
limit: 200,
})
const [filters, setFilters] = useState<Filters>(() => loadStoredFilters())
const [autoRefresh, setAutoRefresh] = useState(true)
// Filter-State in localStorage persistieren. Range bewusst weglassen
// (Datumsbereiche sind „jetzt"-relevant, nicht zwischen Sessions).
useEffect(() => {
try {
const { range: _, ...persistable } = filters
void _
localStorage.setItem(LOGS_FILTERS_KEY, JSON.stringify(persistable))
} catch {
// localStorage voll / disabled — sw allowed, einfach ignorieren.
}
}, [filters])
// Sources-Liste vom Backend (statisch im internal/services/syslogs).
const sourcesQuery = useQuery({
queryKey: ['logs', 'sources'],
@@ -236,6 +270,19 @@ export default function LogsPage() {
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} ${t('logs.limit')}` }))}
style={{ width: 130 }}
/>
<Button
size="small"
onClick={() => setFilters({ sources: [], levels: [], range: null, grep: '', limit: 200 })}
disabled={
filters.sources.length === 0 &&
filters.levels.length === 0 &&
!filters.range &&
!filters.grep &&
filters.limit === 200
}
>
{t('logs.filter.reset')}
</Button>
<Text type="secondary">{t('logs.found', { n: entries.length })}</Text>
</Space>
</Card>

View File

@@ -1,14 +1,15 @@
import { useState } from 'react'
import {
Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message,
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -46,6 +47,24 @@ interface SystemIface {
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
}
interface NTPStatus {
synced: boolean
reference?: string
stratum?: number
offset_ms?: number
freq_ppm?: number
rms_offset_ms?: number
error?: string
}
async function fetchNTPStatus(): Promise<NTPStatus | null> {
try {
const r = await apiClient.get('/ntp/status')
if (!isEnvelope(r.data)) return null
return r.data.data as NTPStatus
} catch { return null }
}
async function listPools(): Promise<Pool[]> {
const r = await apiClient.get('/ntp/pools')
if (!isEnvelope(r.data)) return []
@@ -63,6 +82,12 @@ async function listSystemInterfaces(): Promise<SystemIface[]> {
export default function NTPPage() {
const { t } = useTranslation()
const { data: ntpStatus, refetch: refetchStatus } = useQuery({
queryKey: ['ntp', 'status'],
queryFn: fetchNTPStatus,
refetchInterval: 30_000,
})
return (
<div>
<PageHeader
@@ -70,6 +95,58 @@ export default function NTPPage() {
title={t('ntp.title')}
subtitle={t('ntp.intro')}
/>
<Card
size="small"
className="mb-12"
title={<><ClockCircleOutlined /> {t('ntp.statusCard.title')}</>}
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchStatus()}>{t('common.refresh')}</Button>}
>
{ntpStatus?.error ? (
<Alert type="warning" showIcon message={ntpStatus.error} />
) : ntpStatus ? (
<Row gutter={12}>
<Col xs={12} sm={6}>
<Statistic
title={t('ntp.statusCard.sync')}
value={ntpStatus.synced ? t('ntp.statusCard.synced') : t('ntp.statusCard.notSynced')}
valueStyle={{ color: ntpStatus.synced ? '#16a34a' : '#cf1322', fontSize: 14 }}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title={t('ntp.statusCard.source')}
value={ntpStatus.reference || '—'}
valueStyle={{ fontSize: 13, fontFamily: 'monospace' }}
/>
</Col>
<Col xs={12} sm={6}>
<Statistic
title={t('ntp.statusCard.stratum')}
value={ntpStatus.stratum ?? '—'}
/>
</Col>
<Col xs={12} sm={6}>
<Tooltip title={t('ntp.statusCard.offsetHint')}>
<Statistic
title={t('ntp.statusCard.offset')}
value={ntpStatus.offset_ms != null
? (ntpStatus.offset_ms >= 0 ? '+' : '') + ntpStatus.offset_ms.toFixed(3) + ' ms'
: '—'}
valueStyle={{
fontSize: 13,
color: ntpStatus.offset_ms != null && Math.abs(ntpStatus.offset_ms) > 100
? '#d48806' : undefined,
}}
/>
</Tooltip>
</Col>
</Row>
) : (
<Typography.Text type="secondary">{t('ntp.statusCard.loading')}</Typography.Text>
)}
</Card>
<Tabs
defaultActiveKey="pools"
items={[
@@ -141,6 +218,11 @@ function PoolsTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
}
return (
<>
<DataTable
@@ -149,13 +231,22 @@ function PoolsTab() {
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('ntp.pool.add')}
</Button>
}
emptyContent={
<EmptyState
icon={<ClockCircleOutlined />}
title={t('ntp.pool.emptyTitle')}
description={t('ntp.pool.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('ntp.pool.add')}
</Button>
}
/>
}
/>
<Modal

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { PlusOutlined } from '@ant-design/icons'
import { ClusterOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -149,6 +150,11 @@ export default function InterfacesTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
}
return (
<div>
<Card title={t('networks.systemDiscovered')} className="mb-12" size="small">
@@ -172,13 +178,22 @@ export default function InterfacesTab() {
dataSource={ifs ?? []}
columns={columns}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('networks.addInterface')}
</Button>
}
emptyContent={
<EmptyState
icon={<ClusterOutlined />}
title={t('networks.emptyTitle')}
description={t('networks.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('networks.addInterface')}
</Button>
}
/>
}
/>
<Modal

View File

@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import EmptyState from '../../components/EmptyState'
const { Text } = Typography
@@ -168,6 +169,14 @@ export default function RoutesTab() {
render: (v?: number) => v ?? '—' },
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
metric: 100, table_name: 'main', active: true,
destination: '', gateway: '', dev: '',
})
}
return (
<div>
<Card
@@ -207,13 +216,7 @@ export default function RoutesTab() {
title={t('routes.managedTitle')}
extra={
<Button type="primary" size="small" icon={<PlusOutlined />}
onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
metric: 100, table_name: 'main', active: true,
destination: '', gateway: '', dev: '',
})
}}>
onClick={openCreate}>
{t('routes.add')}
</Button>
}
@@ -226,7 +229,18 @@ export default function RoutesTab() {
dataSource={managed.data ?? []}
columns={managedColumns}
pagination={false}
locale={{ emptyText: t('routes.empty') }}
locale={{ emptyText: (
<EmptyState
icon={<EnvironmentOutlined />}
title={t('routes.emptyTitle')}
description={t('routes.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('routes.add')}
</Button>
}
/>
) }}
style={{ marginTop: 12 }}
/>
</Card>

View File

@@ -5,6 +5,7 @@ import { BranchesOutlined, PlusOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
@@ -119,6 +120,11 @@ export default function RoutingRulesPage() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
}
return (
<div>
<PageHeader
@@ -132,13 +138,22 @@ export default function RoutingRulesPage() {
dataSource={rules ?? []}
columns={columns}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('routing.addRule')}
</Button>
}
emptyContent={
<EmptyState
icon={<BranchesOutlined />}
title={t('routing.emptyTitle')}
description={t('routing.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('routing.addRule')}
</Button>
}
/>
}
/>
<Modal
title={editing ? t('routing.editRule') : t('routing.addRule')}

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'
import { Alert, AutoComplete, Button, Card, Form, Input, Space, Tabs, Tag, Typography, message } from 'antd'
import { SafetyCertificateOutlined } from '@ant-design/icons'
import { Alert, AutoComplete, Button, Card, Col, Form, Input, Popconfirm, Row, Space, Statistic, Tabs, Tag, Tooltip, Typography, message } from 'antd'
import { ExclamationCircleOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import apiClient, { isEnvelope } from '../../api/client'
@@ -79,6 +80,18 @@ function daysUntil(s?: string | null): number | null {
return Math.round((t - Date.now()) / 86_400_000)
}
// relativeAgo: kompakte Vergangenheits-Anzeige für "last_renewed_at".
// Wir übergeben das i18n-`t` als zweites Argument damit die JSX-Render-
// Funktion in der Column-Definition diese Helper-Funktion auch dann
// nutzen kann wenn sie ausserhalb des Components definiert ist.
function relativeAgo(ms: number, t: (k: string, v?: Record<string, unknown>) => string): string {
if (ms < 0) return '—'
if (ms < 60_000) return t('ssl.relAgo.justNow')
if (ms < 3_600_000) return t('ssl.relAgo.minutes', { n: Math.round(ms / 60_000) })
if (ms < 86_400_000) return t('ssl.relAgo.hours', { n: Math.round(ms / 3_600_000) })
return t('ssl.relAgo.days', { n: Math.round(ms / 86_400_000) })
}
export default function SSLPage() {
const { t } = useTranslation()
const qc = useQueryClient()
@@ -139,8 +152,38 @@ export default function SSLPage() {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['tls-certs'] }) },
})
// Force-Renew re-uses /tls-certs/issue — der Endpoint upsertet, also
// ist ein zweiter Issue für dieselbe Domain effektiv ein Renew. Wir
// mappen die Row-ID auf die Domain damit die Mutation nur eine ID
// braucht (UI-seitig).
const renewMut = useMutation({
mutationFn: async (domain: string) => {
const r = await apiClient.post('/tls-certs/issue', { domain })
return r.data
},
onSuccess: () => {
message.success(t('ssl.renewSuccess'))
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
},
onError: (e: Error) => {
message.error(t('ssl.renewFailed') + ': ' + e.message)
},
})
const columns: ColumnsType<TLSCert> = [
{ title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', render: (s: string) => <code>{s}</code> },
{
title: t('ssl.domain'), dataIndex: 'domain', key: 'domain',
render: (s: string, row) => (
<Space size={6}>
<code>{s}</code>
{row.last_error && (
<Tooltip title={row.last_error}>
<ExclamationCircleOutlined style={{ color: '#cf1322' }} />
</Tooltip>
)}
</Space>
),
},
{ title: t('ssl.issuer'), dataIndex: 'issuer', key: 'issuer' },
{
title: t('ssl.status'), dataIndex: 'status', key: 'status',
@@ -156,13 +199,46 @@ export default function SSLPage() {
return `${d}d`
},
},
{
title: t('ssl.lastRenewed'), key: 'lastRenewed', width: 130,
render: (_, row) => {
if (!row.last_renewed_at) {
return <Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
}
const ms = Date.now() - new Date(row.last_renewed_at).getTime()
const rel = relativeAgo(ms, t)
return (
<Tooltip title={new Date(row.last_renewed_at).toLocaleString()}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{rel}</Typography.Text>
</Tooltip>
)
},
},
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<ActionButtons
onDelete={() => delMut.mutate(row.id)}
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
/>
<Space size={4}>
{row.issuer === 'letsencrypt' && (
<Popconfirm
title={t('ssl.renewConfirmTitle')}
description={t('ssl.renewConfirmDesc', { domain: row.domain })}
okText={t('common.yes')} cancelText={t('common.no')}
onConfirm={() => renewMut.mutate(row.domain)}
>
<Button
size="small"
icon={<ReloadOutlined />}
loading={renewMut.isPending && renewMut.variables === row.domain}
>
{t('ssl.renewBtn')}
</Button>
</Popconfirm>
)}
<ActionButtons
onDelete={() => delMut.mutate(row.id)}
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
/>
</Space>
),
},
]
@@ -225,6 +301,19 @@ export default function SSLPage() {
},
]
// Aggregate counts — operator-glance health. Berechnet aus der
// bereits geladenen Liste; keine zusätzlichen API-Calls nötig.
const total = certs?.length ?? 0
const expiring = (certs ?? []).filter((c) => {
const d = daysUntil(c.not_after)
return d != null && d >= 0 && d < 30
}).length
const expired = (certs ?? []).filter((c) => {
const d = daysUntil(c.not_after)
return d != null && d < 0
}).length
const inError = (certs ?? []).filter((c) => !!c.last_error || c.status === 'error').length
return (
<div>
<PageHeader
@@ -233,10 +322,57 @@ export default function SSLPage() {
subtitle={t('ssl.intro')}
/>
{total > 0 && (
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title={t('ssl.statTotal')} value={total} /></Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={t('ssl.statExpiring')}
value={expiring}
valueStyle={expiring > 0 ? { color: '#d48806' } : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={t('ssl.statExpired')}
value={expired}
valueStyle={expired > 0 ? { color: '#cf1322' } : undefined}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title={t('ssl.statErrors')}
value={inError}
valueStyle={inError > 0 ? { color: '#cf1322' } : undefined}
/>
</Card>
</Col>
</Row>
)}
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
<Typography.Title level={5} style={{ marginTop: 24 }}>{t('ssl.installedTitle')}</Typography.Title>
<DataTable rowKey="id" loading={isLoading} dataSource={certs ?? []} columns={columns} />
<DataTable
rowKey="id"
loading={isLoading}
dataSource={certs ?? []}
columns={columns}
emptyContent={
<EmptyState
icon={<SafetyCertificateOutlined />}
title={t('ssl.emptyTitle')}
description={t('ssl.emptyDesc')}
/>
}
/>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { Card, Descriptions, Spin } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd'
import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, ExclamationCircleOutlined, FileSearchOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
@@ -9,16 +10,31 @@ import PageHeader from '../../components/PageHeader'
interface SetupStatus {
completed: boolean
admin_email: string
acme_email: string
fqdn: string
}
interface ContactEmailValues {
admin_email: string
acme_email: string
}
interface SystemHealth {
status: string
version: string
}
interface ChangePasswordValues {
current_password: string
new_password: string
confirm_password: string
}
export default function SettingsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const [msg, msgCtx] = message.useMessage()
const [pwForm] = Form.useForm<ChangePasswordValues>()
const { data: setupStatus, isLoading: loadingSetup } = useQuery({
queryKey: ['setup', 'status'],
@@ -38,12 +54,226 @@ export default function SettingsPage() {
},
})
const [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => {
const r = await apiClient.post('/setup/contact-emails', v)
return r.data
},
onSuccess: () => {
msg.success(t('settings.emailsSaved'))
void qc.invalidateQueries({ queryKey: ['setup', 'status'] })
},
onError: (e: Error) => {
msg.error(t('settings.emailsFailed') + ': ' + e.message)
},
})
const { data: maintenance, refetch: refetchMaintenance } = useQuery({
queryKey: ['system', 'maintenance'],
queryFn: async () => {
const r = await apiClient.get('/system/maintenance')
return isEnvelope(r.data)
? (r.data.data as { enabled: boolean; message: string })
: { enabled: false, message: '' }
},
})
const [maintMessage, setMaintMessage] = useState('')
// Bei Daten-Aktualisierung: lokales Textarea mit DB-Wert syncen,
// wenn der Operator gerade nicht tippt. Trigger via key-Prop unten.
const toggleMaintenance = useMutation({
mutationFn: async (vals: { enabled: boolean; message: string }) => {
const r = await apiClient.post('/system/maintenance', vals)
return r.data
},
onSuccess: () => {
msg.success(t('settings.maintenanceSaved'))
void refetchMaintenance()
},
onError: (e: Error) => {
msg.error(t('settings.maintenanceFailed') + ': ' + e.message)
},
})
const { data: backupRetention } = useQuery({
queryKey: ['system', 'backup-retention'],
queryFn: async () => {
const r = await apiClient.get('/system/backup-retention')
return isEnvelope(r.data)
? (r.data.data as { keep: number; default: number })
: { keep: 0, default: 14 }
},
})
const setBackupRetention = useMutation({
mutationFn: async (keep: number) => {
const r = await apiClient.post('/system/backup-retention', { keep })
return r.data
},
onSuccess: () => {
msg.success(t('settings.backupRetentionSaved'))
void qc.invalidateQueries({ queryKey: ['system', 'backup-retention'] })
},
onError: (e: Error) => msg.error(t('settings.backupRetentionFailed') + ': ' + e.message),
})
const haproxyReload = useMutation({
mutationFn: async () => apiClient.post('/system/haproxy-reload'),
onSuccess: () => msg.success(t('settings.haproxyReloadOk')),
onError: (e: Error) => msg.error(t('settings.haproxyReloadFailed') + ': ' + e.message),
})
const renderConfigs = useMutation({
mutationFn: async () => apiClient.post('/system/render-configs'),
onSuccess: () => msg.success(t('settings.renderConfigsOk')),
onError: (e: Error) => msg.error(t('settings.renderConfigsFailed') + ': ' + e.message),
})
const triggerBackup = useMutation({
mutationFn: async () => apiClient.post('/backups'),
onSuccess: () => msg.success(t('settings.backupNowOk')),
onError: (e: Error) => msg.error(t('settings.backupNowFailed') + ': ' + e.message),
})
const [restartingService, setRestartingService] = useState<string | null>(null)
const serviceRestart = useMutation({
mutationFn: async (service: string) => {
setRestartingService(service)
await apiClient.post('/system/service-restart', { service })
},
onSuccess: (_, service) => {
msg.success(t('settings.serviceRestartOk', { service }))
setRestartingService(null)
void qc.invalidateQueries({ queryKey: ['system', 'services'] })
},
onError: (e: Error, service) => {
msg.error(t('settings.serviceRestartFailed', { service }) + ': ' + e.message)
setRestartingService(null)
},
})
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
const { data: services, refetch: refetchServices } = useQuery({
queryKey: ['system', 'services'],
queryFn: async () => {
const r = await apiClient.get('/system/services')
return isEnvelope(r.data) ? (r.data.data as { services: ServiceStatus[] }).services : []
},
refetchInterval: 15_000,
})
// Restartable services — subset der Allowlist; API lehnt andere ab.
const RESTARTABLE = ['haproxy', 'squid', 'unbound', 'chrony', 'edgeguard-scheduler']
const { data: upgradeStatus, refetch: refetchUpgrade } = useQuery({
queryKey: ['system', 'upgrade-status'],
queryFn: async () => {
const r = await apiClient.get('/system/upgrade-status')
return isEnvelope(r.data)
? (r.data.data as {
state: string
result: string
exec_main_pid: number
exit_code: number
started_at: string
finished_at: string
log: string[]
})
: null
},
refetchInterval: 30_000,
})
const { data: dbSize } = useQuery({
queryKey: ['system', 'db-size'],
queryFn: async () => {
const r = await apiClient.get('/system/db-size')
return isEnvelope(r.data)
? (r.data.data as {
total_bytes: number
human_total: string
top_tables: { name: string; bytes: number; human_size: string }[]
})
: null
},
// DB-Größe ändert sich langsam → 5 min Refresh, eher konservativ.
refetchInterval: 5 * 60_000,
})
const { data: auditRetention } = useQuery({
queryKey: ['system', 'audit-retention'],
queryFn: async () => {
const r = await apiClient.get('/system/audit-retention')
return isEnvelope(r.data)
? (r.data.data as { days: number; default: number })
: { days: 0, default: 90 }
},
})
const setAuditRetention = useMutation({
mutationFn: async (days: number) => {
const r = await apiClient.post('/system/audit-retention', { days })
return r.data
},
onSuccess: () => {
msg.success(t('settings.auditRetentionSaved'))
void qc.invalidateQueries({ queryKey: ['system', 'audit-retention'] })
},
onError: (e: Error) => msg.error(t('settings.auditRetentionFailed') + ': ' + e.message),
})
const { data: autoUpdate } = useQuery({
queryKey: ['system', 'auto-update'],
queryFn: async () => {
const r = await apiClient.get('/system/auto-update')
return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false }
},
})
const toggleAutoUpdate = useMutation({
mutationFn: async (enabled: boolean) => {
const r = await apiClient.post('/system/auto-update', { enabled })
return r.data
},
onSuccess: () => {
msg.success(t('settings.autoUpdateToggled'))
void qc.invalidateQueries({ queryKey: ['system', 'auto-update'] })
},
onError: (e: Error) => {
msg.error(t('settings.autoUpdateFailed') + ': ' + e.message)
},
})
const changePassword = useMutation({
mutationFn: async (v: { current_password: string; new_password: string }) => {
const r = await apiClient.post('/auth/change-password', v)
return r.data
},
onSuccess: () => {
msg.success(t('settings.passwordChanged'))
pwForm.resetFields()
},
onError: (e: Error) => {
// API liefert 401 mit error="invalid_current_password" oder
// 400 mit error-Message; wir zeigen beides als Toast.
msg.error(t('settings.passwordChangeFailed') + ': ' + e.message)
},
})
// Form-Pre-Fill nach Status-Reload: setup-status liefert die zwei
// Email-Felder; wir resetten das Form drauf damit nach Save der frische
// Wert sichtbar wird.
useEffect(() => {
if (setupStatus) {
emailForm.setFieldsValue({
admin_email: setupStatus.admin_email,
acme_email: setupStatus.acme_email,
})
}
}, [setupStatus, emailForm])
if (loadingSetup || loadingHealth) {
return <Spin />
}
return (
<div>
{msgCtx}
<PageHeader
icon={<SettingOutlined />}
title={t('settings.title')}
@@ -54,18 +284,399 @@ export default function SettingsPage() {
<Descriptions column={1}>
<Descriptions.Item label={t('settings.version')}>{health?.version ?? '—'}</Descriptions.Item>
<Descriptions.Item label={t('settings.status')}>{health?.status ?? '—'}</Descriptions.Item>
{dbSize && (
<Descriptions.Item label={t('settings.dbSize')}>
<Space direction="vertical" size={2}>
<Typography.Text>{dbSize.human_total}</Typography.Text>
{dbSize.top_tables.length > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{t('settings.dbSizeTop')}:{' '}
{dbSize.top_tables.slice(0, 3).map(t =>
`${t.name} (${t.human_size})`
).join(', ')}
</Typography.Text>
)}
</Space>
</Descriptions.Item>
)}
</Descriptions>
</Card>
<Card title={t('settings.setupInfo')} size="small">
<Card
title={<><ToolOutlined /> {t('settings.actionsCardTitle')}</>}
className="mb-12"
size="small"
>
<Space wrap>
<Button
icon={<ReloadOutlined />}
loading={haproxyReload.isPending}
onClick={() => haproxyReload.mutate()}
>
{t('settings.haproxyReloadBtn')}
</Button>
<Button
icon={<ReloadOutlined />}
loading={renderConfigs.isPending}
onClick={() => renderConfigs.mutate()}
>
{t('settings.renderConfigsBtn')}
</Button>
<Button
icon={<DatabaseOutlined />}
loading={triggerBackup.isPending}
onClick={() => triggerBackup.mutate()}
>
{t('settings.backupNowBtn')}
</Button>
</Space>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8, marginBottom: 0 }}>
{t('settings.actionsHint')}
</Typography.Paragraph>
</Card>
<Card
title={<><ReloadOutlined /> {t('settings.serviceRestartCardTitle')}</>}
className="mb-12"
size="small"
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchServices()}>{t('common.refresh')}</Button>}
>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
{RESTARTABLE.map((svc) => {
const status = services?.find(s => s.unit === svc + '.service' || s.unit === svc)
return (
<Space key={svc} style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size={6}>
<span
style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: status?.active ? '#22c55e' : '#ef4444',
flexShrink: 0,
}}
/>
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }}>{svc}</Typography.Text>
{status && (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{status.state}
</Typography.Text>
)}
</Space>
<Button
size="small"
icon={<ReloadOutlined />}
loading={restartingService === svc}
onClick={() => serviceRestart.mutate(svc)}
>
{t('settings.serviceRestartBtn')}
</Button>
</Space>
)
})}
</Space>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8, marginBottom: 0 }}>
{t('settings.serviceRestartHint')}
</Typography.Paragraph>
</Card>
{upgradeStatus && upgradeStatus.started_at && (
<Card
title={<><CloudDownloadOutlined /> {t('settings.upgradeStatusCardTitle')}</>}
className="mb-12"
size="small"
extra={
<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchUpgrade()}>
{t('common.refresh')}
</Button>
}
>
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label={t('settings.upgradeStatusStarted')}>
{upgradeStatus.started_at
? new Date(upgradeStatus.started_at).toLocaleString()
: '—'}
</Descriptions.Item>
<Descriptions.Item label={t('settings.upgradeStatusFinished')}>
{upgradeStatus.finished_at
? new Date(upgradeStatus.finished_at).toLocaleString()
: '—'}
</Descriptions.Item>
<Descriptions.Item label={t('settings.upgradeStatusResult')}>
{upgradeStatus.result === 'success' ? (
<Typography.Text type="success">{t('settings.upgradeStatusOk')}</Typography.Text>
) : (
<Typography.Text type="danger">
{upgradeStatus.result || upgradeStatus.state}
{upgradeStatus.exit_code !== 0 && ` (exit ${upgradeStatus.exit_code})`}
</Typography.Text>
)}
</Descriptions.Item>
<Descriptions.Item label={t('settings.upgradeStatusState')}>
{upgradeStatus.state}
</Descriptions.Item>
</Descriptions>
{upgradeStatus.log.length > 0 && (
<details style={{ marginTop: 12 }}>
<summary style={{ cursor: 'pointer', fontSize: 12, color: '#475569' }}>
{t('settings.upgradeStatusShowLog', { n: upgradeStatus.log.length })}
</summary>
<pre style={{
marginTop: 8, padding: 8, background: '#f8fafc',
fontSize: 11, lineHeight: 1.4, overflow: 'auto', maxHeight: 320,
border: '1px solid #e2e8f0', borderRadius: 4,
}}>{upgradeStatus.log.join('\n')}</pre>
</details>
)}
</Card>
)}
<Card title={t('settings.setupInfo')} className="mb-12" size="small">
<Descriptions column={1}>
<Descriptions.Item label={t('settings.adminEmail')}>{setupStatus?.admin_email ?? '—'}</Descriptions.Item>
<Descriptions.Item label={t('settings.fqdn')}>{setupStatus?.fqdn ?? '—'}</Descriptions.Item>
<Descriptions.Item label={t('settings.setupCompleted')}>
{setupStatus?.completed ? t('common.yes') : t('common.no')}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
title={<><MailOutlined /> {t('settings.emailsCardTitle')}</>}
className="mb-12"
size="small"
>
<Form<ContactEmailValues>
form={emailForm}
layout="vertical"
onFinish={(v) => updateEmails.mutate(v)}
>
<Form.Item
label={t('settings.adminEmail')}
name="admin_email"
extra={t('settings.adminEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item
label={t('settings.acmeEmail')}
name="acme_email"
extra={t('settings.acmeEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Button type="primary" htmlType="submit" loading={updateEmails.isPending}>
{t('common.save')}
</Button>
<Button onClick={() => emailForm.resetFields()}>
{t('common.cancel')}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card
title={<><StopOutlined style={{ color: maintenance?.enabled ? '#cf1322' : undefined }} /> {t('settings.maintenanceCardTitle')}</>}
className="mb-12"
size="small"
>
{maintenance?.enabled && (
<Alert
type="error"
showIcon
icon={<ExclamationCircleOutlined />}
message={t('settings.maintenanceActiveTitle')}
description={t('settings.maintenanceActiveDesc')}
className="mb-12"
/>
)}
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space>
<Switch
checked={maintenance?.enabled ?? false}
loading={toggleMaintenance.isPending}
onChange={(checked) => toggleMaintenance.mutate({
enabled: checked,
message: maintMessage || maintenance?.message || '',
})}
/>
<Typography.Text>
{maintenance?.enabled ? t('settings.maintenanceOn') : t('settings.maintenanceOff')}
</Typography.Text>
</Space>
<Form.Item
label={t('settings.maintenanceMessage')}
extra={t('settings.maintenanceMessageHint')}
style={{ marginBottom: 0 }}
>
<Input.TextArea
key={maintenance?.message ?? ''}
defaultValue={maintenance?.message ?? ''}
onChange={(e) => setMaintMessage(e.target.value)}
placeholder={t('settings.maintenanceMessagePlaceholder')}
rows={2}
maxLength={500}
/>
</Form.Item>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.maintenanceHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><DatabaseOutlined /> {t('settings.backupRetentionCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<InputNumber
min={0}
max={365}
step={1}
value={backupRetention?.keep ?? 0}
onChange={(v) => setBackupRetention.mutate((v as number) ?? 0)}
disabled={setBackupRetention.isPending}
addonAfter={t('settings.backupRetentionUnit')}
style={{ width: 200 }}
/>
<Typography.Text type="secondary">
{(backupRetention?.keep ?? 0) === 0
? t('settings.backupRetentionDefault', { n: backupRetention?.default ?? 14 })
: t('settings.backupRetentionCustom', { n: backupRetention?.keep })}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.backupRetentionHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><FileSearchOutlined /> {t('settings.auditRetentionCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<InputNumber
min={0}
max={3650}
step={30}
value={auditRetention?.days ?? 0}
onChange={(v) => setAuditRetention.mutate((v as number) ?? 0)}
disabled={setAuditRetention.isPending}
addonAfter={t('settings.auditRetentionUnit')}
style={{ width: 200 }}
/>
<Typography.Text type="secondary">
{(auditRetention?.days ?? 0) === 0
? t('settings.auditRetentionDefault', { n: auditRetention?.default ?? 90 })
: t('settings.auditRetentionCustom', { n: auditRetention?.days })}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.auditRetentionHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><CloudSyncOutlined /> {t('settings.autoUpdateCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<Switch
checked={autoUpdate?.enabled ?? false}
loading={toggleAutoUpdate.isPending}
onChange={(checked) => toggleAutoUpdate.mutate(checked)}
/>
<Typography.Text>
{autoUpdate?.enabled ? t('settings.autoUpdateOn') : t('settings.autoUpdateOff')}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.autoUpdateHint')}
</Typography.Text>
</Space>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues>
form={pwForm}
layout="vertical"
onFinish={(v) => {
if (v.new_password !== v.confirm_password) {
msg.error(t('settings.passwordMismatch'))
return
}
changePassword.mutate({
current_password: v.current_password,
new_password: v.new_password,
})
}}
// Wir lassen den Submit-Button explizit click-bar — autoComplete
// off damit der Browser nicht "Current password" mit dem im
// Manager gespeicherten autofill'd.
autoComplete="off"
>
<Form.Item
label={t('settings.currentPassword')}
name="current_password"
rules={[{ required: true }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item
label={t('settings.newPassword')}
name="new_password"
extra={t('settings.newPasswordHint')}
rules={[
{ required: true },
{ min: 12, message: t('settings.passwordMinLen') },
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
label={t('settings.confirmPassword')}
name="confirm_password"
dependencies={['new_password']}
rules={[
{ required: true },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error(t('settings.passwordMismatch')))
},
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
loading={changePassword.isPending}
>
{t('settings.changePasswordBtn')}
</Button>
<Button onClick={() => pwForm.resetFields()}>
{t('common.cancel')}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -17,13 +17,27 @@ interface SetupValues {
license_key?: string
}
// FQDN-Regex: erlaubt RFC-1123-Labels (a-z 0-9 -) durch Punkte getrennt,
// 1+ Labels, keine führenden/abschließenden Bindestriche, kein TLD-Zwang
// (wir verifizieren live nicht die DNS-Existenz, nur die Form-Plausibilität).
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
export default function SetupPage({ onComplete: _onComplete }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const onFinish = async (vals: SetupValues) => {
try {
await apiClient.post('/setup/complete', vals)
// FQDN immer lower-casen — der Server erwartet das auch im
// EqualFold-Vergleich beim Login, also vereinheitlichen wir hier
// damit FQDN + ACME-Cert-Subject identisch werden.
const normalised: SetupValues = {
...vals,
admin_email: vals.admin_email.trim().toLowerCase(),
acme_email: vals.acme_email.trim().toLowerCase(),
fqdn: vals.fqdn.trim().toLowerCase(),
}
await apiClient.post('/setup/complete', normalised)
message.success(t('setup.successTitle'))
// Setup doesn't issue a session — the operator must log in.
navigate('/login', { replace: true })
@@ -34,47 +48,75 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 520 }}>
<Typography.Title level={3}>{t('setup.title')}</Typography.Title>
<Typography.Paragraph type="secondary">{t('setup.intro')}</Typography.Paragraph>
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5', padding: 24 }}>
<Card style={{ width: 560 }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{t('setup.intro')}
</Typography.Paragraph>
</Space>
<Alert
type="info"
showIcon
message={t('setup.preflightTitle')}
description={t('setup.preflightDesc')}
style={{ marginBottom: 16 }}
/>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item
label={t('setup.adminEmail')}
name="admin_email"
extra={t('setup.adminEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input autoComplete="email" autoFocus />
<Input autoComplete="email" autoFocus placeholder="admin@example.com" />
</Form.Item>
<Form.Item
label={t('setup.adminPassword')}
name="admin_password"
extra={t('setup.passwordRule')}
rules={[{ required: true, min: 12, message: t('setup.passwordRule') }]}
help={t('setup.passwordRule')}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
label={t('setup.fqdn')}
name="fqdn"
rules={[{ required: true }]}
extra={t('setup.fqdnHint')}
rules={[
{ required: true },
{
pattern: FQDN_RE,
message: t('setup.fqdnInvalid'),
},
]}
>
<Input placeholder="eg.example.com" />
</Form.Item>
<Form.Item
label={t('setup.acmeEmail')}
name="acme_email"
extra={t('setup.acmeEmailHint')}
rules={[{ required: true, type: 'email' }]}
>
<Input />
<Input placeholder="ops@example.com" />
</Form.Item>
<Form.Item
label={t('setup.licenseKey')}
name="license_key"
extra={t('setup.licenseKeyHint')}
>
<Input />
<Input placeholder="EG-XXXX-XXXX-XXXX" />
</Form.Item>
<Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" block>
{t('setup.submit')}
</Button>

View File

@@ -4,12 +4,13 @@ import {
Row, Select, Switch, Tag, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { KeyOutlined, PlusOutlined } from '@ant-design/icons'
import { KeyOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
import type { WGInterface } from './types'
@@ -113,6 +114,14 @@ export default function ClientsTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
allowed_ips: '0.0.0.0/0,::/0', persistent_keepalive: 25,
role: 'wan', active: true, generate_keypair: true,
})
}
return (
<>
<Alert
@@ -127,16 +136,22 @@ export default function ClientsTab() {
dataSource={clients ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
allowed_ips: '0.0.0.0/0,::/0', persistent_keepalive: 25,
role: 'wan', active: true, generate_keypair: true,
})
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('wg.iface.addClient')}
</Button>
}
emptyContent={
<EmptyState
icon={<ThunderboltOutlined />}
title={t('wg.iface.emptyClientTitle')}
description={t('wg.iface.emptyClientDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('wg.iface.addClient')}
</Button>
}
/>
}
/>
<Modal

View File

@@ -6,13 +6,14 @@ import {
import type { ColumnsType } from 'antd/es/table'
import {
DownloadOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
TeamOutlined,
TeamOutlined, ThunderboltOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
import type { WGInterface, WGPeer } from './types'
@@ -166,6 +167,13 @@ export default function ServersTab() {
},
]
const openCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
listen_port: 51820, role: 'wan', active: true, generate_keypair: true,
})
}
return (
<>
<Alert
@@ -180,15 +188,22 @@ export default function ServersTab() {
dataSource={servers ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
listen_port: 51820, role: 'wan', active: true, generate_keypair: true,
})
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('wg.iface.addServer')}
</Button>
}
emptyContent={
<EmptyState
icon={<ThunderboltOutlined />}
title={t('wg.iface.emptyServerTitle')}
description={t('wg.iface.emptyServerDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{t('wg.iface.addServer')}
</Button>
}
/>
}
/>
<Modal
@@ -407,6 +422,14 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
},
]
const openPeerCreate = () => {
setCreating(true); form.resetFields()
form.setFieldsValue({
allowed_ips: '', enabled: true,
generate_keypair: true, generate_psk: false,
})
}
return (
<Drawer
open={open}
@@ -427,16 +450,22 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
dataSource={peers ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({
allowed_ips: '', enabled: true,
generate_keypair: true, generate_psk: false,
})
}}>
<Button type="primary" icon={<PlusOutlined />} onClick={openPeerCreate}>
{t('wg.peer.add')}
</Button>
}
emptyContent={
<EmptyState
icon={<TeamOutlined />}
title={t('wg.peer.emptyTitle')}
description={t('wg.peer.emptyDesc')}
action={
<Button type="primary" icon={<PlusOutlined />} onClick={openPeerCreate}>
{t('wg.peer.add')}
</Button>
}
/>
}
/>
<Modal