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,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
}