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:
@@ -35,6 +35,13 @@ interface DataTableProps<T> extends Omit<TableProps<T>, 'pagination'> {
|
||||
// ProTable mobile mode. Pass undefined to use the default Table
|
||||
// also on mobile (with horizontal scroll).
|
||||
renderMobileCard?: (record: T, index: number) => ReactNode
|
||||
// emptyContent: ReactNode das in der Table-Locale `emptyText` landet
|
||||
// wenn dataSource leer ist UND keine Suche aktiv ist. Erlaubt
|
||||
// pro-Seite einen kontext-sensitiven Empty-State (z. B. <EmptyState
|
||||
// title="Noch keine Domains" action={…} />) statt dem generischen
|
||||
// "Keine Einträge". Bei aktiver Suche zeigt die Table weiter den
|
||||
// Standard-Empty-State (Operator soll sehen dass der Filter beißt).
|
||||
emptyContent?: ReactNode
|
||||
}
|
||||
|
||||
function inferSorter<T>(dataIndex: string | string[] | undefined) {
|
||||
@@ -80,6 +87,7 @@ export default function DataTable<T extends object>(
|
||||
toolbar,
|
||||
extraActions,
|
||||
renderMobileCard,
|
||||
emptyContent,
|
||||
rowKey,
|
||||
loading,
|
||||
...rest
|
||||
@@ -165,6 +173,12 @@ export default function DataTable<T extends object>(
|
||||
{...rest}
|
||||
dataSource={filtered}
|
||||
columns={enhancedCols}
|
||||
// emptyContent zeigt sich nur wenn keine Suche aktiv ist —
|
||||
// sonst soll der Operator sehen "Filter beißt", nicht
|
||||
// "Page is empty, add first row".
|
||||
locale={emptyContent && !search
|
||||
? { emptyText: emptyContent }
|
||||
: { emptyText: t('common.noData') }}
|
||||
pagination={{
|
||||
pageSize: perPage,
|
||||
current: page,
|
||||
|
||||
45
management-ui/src/components/EmptyState.tsx
Normal file
45
management-ui/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Empty, Space, Typography } from 'antd'
|
||||
|
||||
// EmptyState ist die zentrale Komponente die alle Listing-Seiten als
|
||||
// `locale={{ emptyText: <EmptyState … /> }}` an AntD-Tables hängen.
|
||||
//
|
||||
// Statt nur "Keine Einträge" sehen Operator/innen warum die Tabelle leer
|
||||
// ist (frische Installation? Filter zu eng?) und welche konkrete Aktion
|
||||
// als nächstes Sinn ergibt.
|
||||
//
|
||||
// Props:
|
||||
// icon — z. B. <GlobalOutlined /> aus @ant-design/icons (optional)
|
||||
// title — der eine, klare Hauptsatz: "Noch keine Domains."
|
||||
// description — was zu tun ist: "Klick oben rechts auf …"
|
||||
// action — primary-Button-JSX (optional; manchmal reicht der
|
||||
// Hinweis dass es weiter oben einen Button gibt)
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<Empty
|
||||
image={icon ? (
|
||||
<div style={{ fontSize: 56, color: '#94A3B8', lineHeight: 1 }}>{icon}</div>
|
||||
) : Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
imageStyle={{ height: 70, marginBottom: 12, display: 'flex', justifyContent: 'center' }}
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ paddingTop: 4 }}>
|
||||
<Typography.Text strong>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{action}
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
96
management-ui/src/components/ErrorBoundary.tsx
Normal file
96
management-ui/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
|
||||
// Lazy-Chunk-Loadfehler, die auf flakigem Mobilfunk häufig sind) und
|
||||
// rendert eine sichtbare Fehlerseite statt #root leer zu lassen.
|
||||
// Ohne diese Boundary endet jeder Render-Throw als „blank page".
|
||||
//
|
||||
// Wir loggen den Fehler in die Browser-Console (für Remote-Debug via
|
||||
// Safari-Inspector/Chrome-Remote) und zeigen dem Operator die
|
||||
// Fehlermeldung wörtlich — kein Translation-Layer, weil i18n selbst
|
||||
// schon kaputt sein kann.
|
||||
|
||||
interface State { error: Error | null }
|
||||
|
||||
export default class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[ErrorBoundary]', error, info.componentStack)
|
||||
}
|
||||
|
||||
reset = () => { this.setState({ error: null }) }
|
||||
|
||||
render() {
|
||||
const err = this.state.error
|
||||
if (!err) return this.props.children
|
||||
const isChunkErr = /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test(err.message)
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
background: '#F8FAFC',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 520,
|
||||
width: '100%',
|
||||
background: '#fff',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#0F172A', marginBottom: 6 }}>
|
||||
EdgeGuard konnte nicht laden
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748B', marginBottom: 16 }}>
|
||||
{isChunkErr
|
||||
? 'Ein Teil der App konnte nicht aus dem Netz geladen werden. Das passiert häufig bei wechselndem Mobilfunk-Empfang. Versuche es mit einem Reload.'
|
||||
: 'Beim Initialisieren der Oberfläche ist ein Fehler aufgetreten.'}
|
||||
</div>
|
||||
<pre style={{
|
||||
background: '#F1F5F9',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
fontSize: 11,
|
||||
color: '#475569',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{err.name}: {err.message}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { this.reset(); window.location.reload() }}
|
||||
style={{
|
||||
background: '#0EA5E9',
|
||||
color: '#fff',
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import UpdateBanner from '../UpdateBanner'
|
||||
import LicenseBanner from '../LicenseBanner'
|
||||
import MaintenanceBanner from '../MaintenanceBanner'
|
||||
|
||||
// PAGE_TITLES maps the pathname to an i18n nav key. Header reads
|
||||
// this to render "where you are". Empty fallback = app.title.
|
||||
@@ -20,6 +21,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/logs': 'nav.logs',
|
||||
'/audit': 'nav.audit',
|
||||
'/backups': 'nav.backups',
|
||||
'/diagnostics': 'nav.diagnostics',
|
||||
'/alerts': 'nav.alerts',
|
||||
@@ -50,6 +52,7 @@ export default function AppLayout() {
|
||||
|
||||
<main className="main-content">
|
||||
<Header pageTitle={title} onMenuToggle={() => setSidebarOpen(true)} />
|
||||
<MaintenanceBanner />
|
||||
<LicenseBanner />
|
||||
<UpdateBanner />
|
||||
<div className="content-area">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import UpdateBanner from '../UpdateBanner'
|
||||
|
||||
interface HeaderProps {
|
||||
pageTitle: string
|
||||
@@ -40,6 +41,7 @@ export default function Header({ pageTitle, onMenuToggle }: HeaderProps) {
|
||||
<h1 className="header-title">{pageTitle}</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<UpdateBanner compact />
|
||||
<Select
|
||||
size="small"
|
||||
value={i18n.resolvedLanguage ?? 'de'}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
@@ -10,6 +13,7 @@ import {
|
||||
DashboardOutlined,
|
||||
EyeOutlined,
|
||||
FileSearchOutlined,
|
||||
AuditOutlined,
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
@@ -76,6 +80,7 @@ const NAV: NavSection[] = [
|
||||
items: [
|
||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
|
||||
{ path: '/audit', labelKey: 'nav.audit', icon: <AuditOutlined /> },
|
||||
{ path: '/diagnostics', labelKey: 'nav.diagnostics', icon: <ToolOutlined /> },
|
||||
{ path: '/alerts', labelKey: 'nav.alerts', icon: <BellOutlined /> },
|
||||
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
|
||||
@@ -85,8 +90,6 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.78'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
// - Section-Label-Div NEBEN dem <ul>, nicht verschachtelt
|
||||
@@ -97,6 +100,22 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
// Version aus /system/health ziehen damit nach jedem Self-Upgrade
|
||||
// automatisch die neue Versionsnummer in der Sidebar erscheint —
|
||||
// ohne Hardcode, der beim Vergessen den Eindruck erweckt das Update
|
||||
// sei nicht durchgelaufen.
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/health')
|
||||
return isEnvelope(r.data) ? (r.data.data as { version?: string }) : { version: '' }
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const version = health?.version || '…'
|
||||
|
||||
return (
|
||||
<nav className={`sidebar${isOpen ? ' open' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
@@ -139,7 +158,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-version">v{VERSION}</div>
|
||||
<div className="sidebar-version">v{version}</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
41
management-ui/src/components/MaintenanceBanner.tsx
Normal file
41
management-ui/src/components/MaintenanceBanner.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Alert } from 'antd'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
interface MaintenanceState {
|
||||
enabled: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// MaintenanceBanner: globaler Hinweis im AppLayout dass Whole-Box-
|
||||
// Maintenance-Mode aktiv ist. Polled /system/maintenance alle 60s.
|
||||
// Pflicht-sichtbar damit der Admin nie vergisst dass er ALLE Customer-
|
||||
// Domains gerade auf 503 hat.
|
||||
export default function MaintenanceBanner() {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['system', 'maintenance'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/maintenance')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as MaintenanceState)
|
||||
: { enabled: false, message: '' }
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
if (!data?.enabled) return null
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
banner
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message={t('settings.maintenanceActiveTitle')}
|
||||
description={data.message || t('settings.maintenanceActiveDesc')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Popconfirm, Space, message } from 'antd'
|
||||
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
|
||||
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -6,6 +6,15 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
interface UpdateBannerProps {
|
||||
// compact: rendert nur einen kleinen „Jetzt prüfen"-Button (z. B. im
|
||||
// Header), unabhängig davon ob aktuell ein Update verfügbar ist. So
|
||||
// hat der Operator immer einen Weg den Server-seitigen Throttle zu
|
||||
// umgehen — sonst Catch-22 wenn der Banner mangels Update nicht
|
||||
// rendert. Pattern 1:1 aus mail-gateway/Dashboard/v2/UpdateBanner.
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
interface PackageVersions { [key: string]: string }
|
||||
interface SystemHealth { status: string; version: string }
|
||||
|
||||
@@ -28,7 +37,7 @@ function allUpdates(v: PackageVersions): PendingUpdate[] {
|
||||
return out
|
||||
}
|
||||
|
||||
export default function UpdateBanner() {
|
||||
export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}) {
|
||||
const { t } = useTranslation()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
@@ -67,8 +76,15 @@ export default function UpdateBanner() {
|
||||
const forceCheck = async () => {
|
||||
setForceChecking(true)
|
||||
try {
|
||||
await pkgVersions.refetch()
|
||||
const fresh = pkgVersions.data ?? {}
|
||||
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
|
||||
// apt-get update. Ohne den Force-Hint würde der Endpoint
|
||||
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
|
||||
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
|
||||
const r = await apiClient.get('/system/package-versions?force=1')
|
||||
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
|
||||
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
|
||||
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
|
||||
void pkgVersions.refetch()
|
||||
const found = allUpdates(fresh).length > 0
|
||||
msg[found ? 'success' : 'info'](
|
||||
found ? t('update.checkDone') : t('update.noUpdate'),
|
||||
@@ -128,6 +144,33 @@ export default function UpdateBanner() {
|
||||
})
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
|
||||
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
|
||||
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
|
||||
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
|
||||
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
|
||||
// "die roten Banner können weg, der gelbe Banner reicht").
|
||||
if (updateAvailable) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Tooltip title={t('update.checkNowHint')}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={forceChecking}
|
||||
onClick={forceCheck}
|
||||
>
|
||||
{t('update.checkNow')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!updateAvailable && !upgrading) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user