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

@@ -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>
)
}