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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user