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:
@@ -28,6 +28,7 @@ const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
||||
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||
const AuditPage = lazy(() => import('./pages/Audit'))
|
||||
const BackupsPage = lazy(() => import('./pages/Backups'))
|
||||
const DiagnosticsPage = lazy(() => import('./pages/Diagnostics'))
|
||||
const AlertsPage = lazy(() => import('./pages/Alerts'))
|
||||
@@ -118,6 +119,7 @@ export default function App() {
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/diagnostics" element={<DiagnosticsPage />} />
|
||||
<Route path="/alerts" element={<AlertsPage />} />
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall-Log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit-Log",
|
||||
"backups": "Backups",
|
||||
"diagnostics": "Diagnose",
|
||||
"alerts": "Alarme",
|
||||
@@ -44,7 +45,8 @@
|
||||
"addrObj": "Adress-Objekte",
|
||||
"addrGrp": "Adress-Gruppen",
|
||||
"services": "Services",
|
||||
"svcGrp": "Service-Gruppen"
|
||||
"svcGrp": "Service-Gruppen",
|
||||
"system": "System-Regeln"
|
||||
},
|
||||
"zone": {
|
||||
"name": "Name",
|
||||
@@ -55,31 +57,41 @@
|
||||
"namePattern": "Nur Kleinbuchstaben, Ziffern, _ und -; muss mit Buchstaben beginnen, max. 32 Zeichen.",
|
||||
"add": "Zone hinzufügen",
|
||||
"edit": "Zone bearbeiten",
|
||||
"deleteConfirm": "Zone {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Zone {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine eigenen Firewall-Zonen.",
|
||||
"emptyDesc": "Zonen gruppieren Interfaces (lan, wan, dmz). Vordefinierte Zonen sind bereits da; eigene anlegen wenn du z. B. eine separate dmz-Zone oder eine wg-Zone fürs VPN brauchst."
|
||||
},
|
||||
"ao": {
|
||||
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung",
|
||||
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten",
|
||||
"deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Adress-Objekte.",
|
||||
"emptyDesc": "Wiederverwendbare benannte IPs/Netze/Ranges für Firewall- und NAT-Regeln (z. B. office-net = 10.0.0.0/24, mailbox-1 = 10.0.1.42)."
|
||||
},
|
||||
"ag": {
|
||||
"name": "Name", "members": "Mitglieder", "description": "Beschreibung",
|
||||
"add": "Adress-Gruppe hinzufügen", "edit": "Adress-Gruppe bearbeiten",
|
||||
"selectMembers": "Adress-Objekte wählen",
|
||||
"deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Adress-Gruppen.",
|
||||
"emptyDesc": "Bündele mehrere Adress-Objekte zu einer Gruppe (z. B. office-locations = [hq-net, branch-1-net, branch-2-net]) — Regeln müssen dann nur eine Gruppe referenzieren."
|
||||
},
|
||||
"svc": {
|
||||
"name": "Name", "proto": "Protokoll", "ports": "Ports",
|
||||
"portStart": "Port (Start)", "portEnd": "Port (Ende)",
|
||||
"description": "Beschreibung", "builtinHint": "Vordefiniert — nicht editierbar",
|
||||
"add": "Service hinzufügen", "edit": "Service bearbeiten",
|
||||
"deleteConfirm": "Service {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Service {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine eigenen Services.",
|
||||
"emptyDesc": "Vordefinierte (HTTP, HTTPS, SSH, …) sind bereits da. Eigene Services für app-spezifische Ports (z. B. mailcow-imaps tcp/993) erleichtern Regeln deutlich."
|
||||
},
|
||||
"sg": {
|
||||
"name": "Name", "members": "Mitglieder", "description": "Beschreibung",
|
||||
"add": "Service-Gruppe hinzufügen", "edit": "Service-Gruppe bearbeiten",
|
||||
"selectMembers": "Services wählen",
|
||||
"deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Service-Gruppen.",
|
||||
"emptyDesc": "Bündele mehrere Services zu einer Gruppe (z. B. web-stack = [HTTP, HTTPS, HTTP/3]) — eine Regel mit der Gruppe ersetzt drei Regeln pro Service."
|
||||
},
|
||||
"rule": {
|
||||
"name": "Name", "priority": "Priority", "enabled": "Aktiv", "log": "Logging",
|
||||
@@ -90,7 +102,9 @@
|
||||
"serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe",
|
||||
"comment": "Kommentar",
|
||||
"add": "Regel hinzufügen", "edit": "Regel bearbeiten",
|
||||
"deleteConfirm": "Diese Regel wirklich löschen?"
|
||||
"deleteConfirm": "Diese Regel wirklich löschen?",
|
||||
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
|
||||
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen."
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv",
|
||||
@@ -102,7 +116,9 @@
|
||||
"targetAddr": "Ziel-Adresse", "targetPortStart": "Ziel-Port (Start)", "targetPortEnd": "Ziel-Port (Ende)",
|
||||
"comment": "Kommentar",
|
||||
"add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten",
|
||||
"deleteConfirm": "Diese NAT-Regel wirklich löschen?"
|
||||
"deleteConfirm": "Diese NAT-Regel wirklich löschen?",
|
||||
"emptyTitle": "Noch keine NAT-Regeln.",
|
||||
"emptyDesc": "DNAT (z. B. extern :2030 → intern 10.10.20.12:22 für SSH zu einem internen Host) oder SNAT/MASQUERADE (Internet-Zugang für ein internes Subnetz über die Box-IP)."
|
||||
},
|
||||
"sys": {
|
||||
"title": "System-Regeln (immer aktiv)",
|
||||
@@ -125,6 +141,8 @@
|
||||
"systemDiscovered": "System-Interfaces (read-only)",
|
||||
"addInterface": "Interface hinzufügen",
|
||||
"editInterface": "Interface bearbeiten",
|
||||
"emptyTitle": "Noch keine verwalteten Interfaces.",
|
||||
"emptyDesc": "System-Interfaces (siehe oben) werden read-only erkannt. Verwaltete Interfaces sind das was EdgeGuard selbst anlegt — VLANs, Bridges, Bond, GRE-Tunnels — und in /etc/network/interfaces.d rendert.",
|
||||
"name": "Name",
|
||||
"type": "Typ",
|
||||
"parent": "Parent-Interface",
|
||||
@@ -152,6 +170,8 @@
|
||||
"managedTitle": "Verwaltete Adressen",
|
||||
"family": "Familie",
|
||||
"addAddress": "Adresse hinzufügen",
|
||||
"emptyTitle": "Noch keine eigenen IP-Adressen.",
|
||||
"emptyDesc": "Lege deine erste IP an — Floating-VIPs für HA-Failover oder zusätzliche Listen-Adressen für HAProxy/Squid/Unbound. Box-eigene Distro-IPs werden oben unter \"Erkannte IPs\" gelesen.",
|
||||
"editAddress": "Adresse bearbeiten",
|
||||
"interface": "Interface",
|
||||
"selectInterface": "Interface wählen",
|
||||
@@ -192,12 +212,19 @@
|
||||
"setup": {
|
||||
"title": "Erst-Einrichtung",
|
||||
"intro": "Lege den Admin-Account an, gib die öffentliche FQDN an und – optional – einen Lizenzschlüssel. Ohne Lizenz startet eine 30-Tage-Trial.",
|
||||
"preflightTitle": "Vor dem Klick auf \"Setup abschließen\"",
|
||||
"preflightDesc": "Die FQDN muss bereits per DNS auf diese Box zeigen (A/AAAA-Record). Ohne DNS-Auflösung scheitert die spätere ACME-HTTP-01-Challenge — dein Browser-URL muss schon jetzt diese FQDN sein, sonst klemmt's spätestens beim ersten Let's-Encrypt-Issue.",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"adminEmailHint": "Login-Identifier für die Management-UI. Wird NICHT für ausgehende Mails benutzt (das ist die ACME-E-Mail unten bzw. die SMTP-Settings im Alerts-Channel).",
|
||||
"adminPassword": "Admin-Passwort",
|
||||
"passwordRule": "Mindestens 12 Zeichen.",
|
||||
"fqdn": "Öffentliche FQDN",
|
||||
"fqdnHint": "Vollqualifizierter Hostname dieser Box (z. B. eg.example.com). Wird Subject-CN des Self-Signed-Bootstrap-Cert UND wandert in das ACME-Cert sobald Let's Encrypt ausgestellt hat. Muss per DNS auf die Public-IP zeigen.",
|
||||
"fqdnInvalid": "Sieht nicht wie eine gültige FQDN aus (mindestens label.tld).",
|
||||
"acmeEmail": "ACME-/Let's-Encrypt-E-Mail",
|
||||
"acmeEmailHint": "Wird Let's Encrypt als Account-Contact mitgegeben (Expiry-Warnungen + Compliance-Mails von LE). Kann gleich der Admin-E-Mail sein, muss aber nicht.",
|
||||
"licenseKey": "Lizenzschlüssel (optional)",
|
||||
"licenseKeyHint": "Wenn vorhanden: 30-Tage-Trial wird übersprungen, Features unmittelbar freigeschaltet. Kann auch später unter License nachgereicht werden.",
|
||||
"submit": "Setup abschließen",
|
||||
"successTitle": "Setup abgeschlossen",
|
||||
"successHint": "Du wirst zur Anmeldung weitergeleitet."
|
||||
@@ -211,14 +238,64 @@
|
||||
"intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.",
|
||||
"addDomain": "Domain hinzufügen",
|
||||
"editDomain": "Domain bearbeiten",
|
||||
"emptyTitle": "Noch keine Domains.",
|
||||
"emptyDesc": "Lege deine erste Domain an — HAProxy terminiert dann TLS für diesen Hostnamen und routet an das gewählte Backend.",
|
||||
"name": "Name",
|
||||
"active": "Aktiv",
|
||||
"primaryBackend": "Primary-Backend",
|
||||
"primaryBackendHint": "Catch-all-Backend für Requests, die kein Routing-Regel-Match haben. Optional — leer lassen, wenn alles über Routing-Regeln läuft.",
|
||||
"selectBackend": "Backend wählen",
|
||||
"noBackend": "kein Backend",
|
||||
"quickBackendBtn": "+ neu",
|
||||
"quickBackendBtnHint": "Backend + ersten Upstream-Server in einem Klick anlegen — spart den Umweg über die Backends-Seite.",
|
||||
"quickBackendTitle": "Backend schnell anlegen",
|
||||
"quickBackendName": "Backend-Name",
|
||||
"quickBackendScheme": "Schema",
|
||||
"quickBackendAddress": "Server-Adresse (Erster Upstream)",
|
||||
"quickBackendPort": "Port",
|
||||
"quickBackendCreated": "Backend + Server angelegt und ausgewählt.",
|
||||
"quickBackendFailed": "Quick-Backend-Anlage fehlgeschlagen",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"tlsCert": "TLS-Cert",
|
||||
"tlsCertValid": "gültig",
|
||||
"tlsCertExpiring": "noch {{days}}d",
|
||||
"tlsCertExpired": "abgelaufen",
|
||||
"tlsCertError": "Fehler",
|
||||
"tlsCertNone": "fehlt",
|
||||
"tlsCertNoneHint": "Kein eigener TLS-Cert für diese Domain — HAProxy serviert das Bootstrap-Self-Signed-Cert. Im SSL-Tab issuen.",
|
||||
"hstsMaxAge": "HSTS max-age (Sek.)",
|
||||
"hstsMaxAgeHint": "Wie lange Browser den HTTPS-Zwang cachen. Empfehlung 31536000 (1 Jahr).",
|
||||
"hstsSubdomains": "includeSubDomains",
|
||||
"hstsSubdomainsHint": "Erstreckt HSTS auf alle Sub-Domains. Nur aktivieren wenn alle Subs ausschließlich HTTPS sprechen.",
|
||||
"hstsPreload": "preload",
|
||||
"hstsPreloadHint": "Setzt den preload-Flag damit die Domain in die hstspreload.org-Liste aufgenommen werden kann. Voraussetzung: max-age ≥ 31536000 + includeSubDomains an.",
|
||||
"maintenance": "Wartungs-Modus",
|
||||
"maintenanceHint": "An: alle Requests auf diese Domain bekommen direkt 503 von HAProxy. Backends werden nicht kontaktiert.",
|
||||
"maintenanceMessage": "Wartungs-Meldung",
|
||||
"maintenanceMessagePlaceholder": "Service vorübergehend nicht verfügbar.",
|
||||
"wwwRedirect": "www-Redirect",
|
||||
"wwwRedirectHint": "Kanonisiert die Domain. „nach naked\": Name=example.com → www.example.com leitet auf example.com. „nach www\": Name=www.example.com → example.com leitet auf www.example.com.",
|
||||
"wwwRedirectNone": "Kein Redirect",
|
||||
"wwwRedirectToNaked": "→ naked (ohne www)",
|
||||
"wwwRedirectToWWW": "→ www",
|
||||
"rateLimit": "Rate-Limit (pro Client-IP)",
|
||||
"rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.",
|
||||
"maxBody": "Max. Request-Body",
|
||||
"maxBodyHint": "Cap auf Content-Length-Header. Größere Requests bekommen 413. Nicht erkannt: Chunked-Bodies (HAProxy bufferd nicht standardmäßig). 0 = aus.",
|
||||
"headersBtn": "Headers",
|
||||
"headersTitle": "Response-Headers — {{name}}",
|
||||
"headersHint": "Diese Header werden von HAProxy auf jede Response für diese Domain gesetzt (http-response set-header). Reihenfolge via Position.",
|
||||
"headersEmpty": "Noch keine Custom-Headers konfiguriert.",
|
||||
"addHeader": "Header hinzufügen",
|
||||
"editHeader": "Header bearbeiten",
|
||||
"headerName": "Name",
|
||||
"headerNameHint": "Nur Buchstaben, Zahlen, Bindestriche. Pro Domain case-insensitive eindeutig.",
|
||||
"headerNamePattern": "Nur a-z, A-Z, 0-9, '-'",
|
||||
"headerValue": "Wert",
|
||||
"headerPosition": "Position",
|
||||
"headerDeleteConfirm": "Header „{{name}}\" wirklich löschen?",
|
||||
"settingsSection": "HAProxy-Einstellungen",
|
||||
"notes": "Notizen",
|
||||
"actions": "Aktionen",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -229,11 +306,14 @@
|
||||
"title": "Backends",
|
||||
"intro": "Upstream-Pools (Backend = N Server). HAProxy verteilt laut LB-Algorithmus; Health-Check-Pfad aktiviert HTTP-Probes alle 5s pro Server.",
|
||||
"addBackend": "Backend-Pool hinzufügen",
|
||||
"emptyTitle": "Noch keine Backend-Pools.",
|
||||
"emptyDesc": "Lege deinen ersten Pool an — definiere Upstream-Server (z. B. App-Server / Mailbox-Knoten) damit Domains darauf routen können.",
|
||||
"editBackend": "Backend-Pool bearbeiten",
|
||||
"name": "Name",
|
||||
"scheme": "Schema",
|
||||
"target": "Ziel",
|
||||
"healthCheck": "Health-Check-Pfad",
|
||||
"liveStatus": "HAProxy-Status",
|
||||
"active": "Aktiv",
|
||||
"usedBy": "Genutzt von",
|
||||
"noDomain": "keine Domain",
|
||||
@@ -271,6 +351,8 @@
|
||||
"title": "Routing-Regeln",
|
||||
"intro": "Pfad-Präfix → Backend-Mapping pro Domain. Niedrige Priority gewinnt; Catch-all per Domain.primary_backend.",
|
||||
"addRule": "Regel hinzufügen",
|
||||
"emptyTitle": "Noch keine Routing-Regeln.",
|
||||
"emptyDesc": "Pfad-spezifisches Routing — z. B. domain.tld/api → Backend A, domain.tld/* → Backend B. Optional. Ohne Regeln nutzt HAProxy die Primary-Backend-Auswahl der Domain.",
|
||||
"editRule": "Regel bearbeiten",
|
||||
"domain": "Domain",
|
||||
"pathPrefix": "Pfad-Präfix",
|
||||
@@ -313,8 +395,35 @@
|
||||
"configHash": "Config-Hash",
|
||||
"version": "Version",
|
||||
"lastSeen": "Last seen",
|
||||
"mgmtIp": "MGMT-IP"
|
||||
}
|
||||
"mgmtIp": "MGMT-IP",
|
||||
"load": "Load 1/5/15",
|
||||
"mem": "Memory",
|
||||
"disk": "Disk",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime",
|
||||
"fetchMs": "Fetch"
|
||||
},
|
||||
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
|
||||
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
|
||||
"certCardTitle": "Cluster-TLS-Zertifikate",
|
||||
"certCALabel": "Cluster-CA",
|
||||
"certPeerLabel": "Peer-Cert (diese Node)",
|
||||
"certExpiry": "Läuft ab in",
|
||||
"renewSelfBtn": "Peer-Cert erneuern",
|
||||
"renewSelfConfirm": "Peer-Cert mit lokaler CA neu signieren (1 Jahr)? edgeguard-api Restart danach erforderlich.",
|
||||
"certRenewedRestartHint": "Cert erneuert — bitte sudo systemctl restart edgeguard-api ausführen.",
|
||||
"certRenewFailed": "Cert-Erneuerung fehlgeschlagen",
|
||||
"removePeerBtn": "Entfernen",
|
||||
"removePeerConfirmTitle": "Peer wirklich aus dem Cluster entfernen?",
|
||||
"removePeerConfirmDesc": "{{fqdn}} wird aus ha_nodes gelöscht. Firewall-Renderer entfernt seine IP aus dem peer_ipv4-Set. Auf der Peer-Seite läuft edgeguard-api weiter; manuell stoppen + cluster-tls löschen für vollständigen Decommission.",
|
||||
"removePeerOk": "Peer entfernt.",
|
||||
"removePeerFailed": "Peer-Entfernung fehlgeschlagen",
|
||||
"generateJoinToken": "Join-Token erzeugen",
|
||||
"joinTokenTitle": "Cluster-Join-Token",
|
||||
"joinTokenOneShot": "Einmalig anzeigen, einmalig nutzbar",
|
||||
"joinTokenOneShotDesc": "Token jetzt sicher übertragen — er erscheint nie wieder. Gültig bis {{expires}}. Beim Einlösen via cluster-join wird er server-seitig als verbraucht markiert.",
|
||||
"joinTokenFailed": "Token-Generierung fehlgeschlagen",
|
||||
"joinCmdLabel": "Auf dem neuen Node ausführen:"
|
||||
},
|
||||
"ssl": {
|
||||
"title": "SSL-Zertifikate",
|
||||
@@ -338,22 +447,112 @@
|
||||
"uploadButton": "Hochladen",
|
||||
"issueSuccess": "Zertifikat ausgestellt + installiert.",
|
||||
"uploadSuccess": "Zertifikat hochgeladen + installiert.",
|
||||
"renewBtn": "Renew",
|
||||
"renewConfirmTitle": "Zertifikat jetzt erneuern?",
|
||||
"renewConfirmDesc": "Triggert eine ACME-HTTP-01-Challenge für {{domain}}. Let's Encrypt hat Rate-Limits (50 Issues/Domain/Woche) — nicht öfter als nötig nutzen.",
|
||||
"renewSuccess": "Zertifikat erneuert + installiert.",
|
||||
"renewFailed": "Renewal fehlgeschlagen",
|
||||
"deleteConfirm": "Zertifikat für {{domain}} löschen? HAProxy fällt für diese Domain auf das Default-Cert zurück.",
|
||||
"installedTitle": "Installierte Zertifikate",
|
||||
"lastRenewed": "Zuletzt erneuert",
|
||||
"statTotal": "Zertifikate gesamt",
|
||||
"statExpiring": "< 30 Tage gültig",
|
||||
"statExpired": "Abgelaufen",
|
||||
"statErrors": "Mit Fehler",
|
||||
"relAgo": {
|
||||
"justNow": "gerade eben",
|
||||
"minutes": "vor {{n}} min",
|
||||
"hours": "vor {{n}} h",
|
||||
"days": "vor {{n}} Tagen"
|
||||
},
|
||||
"emptyTitle": "Noch keine Zertifikate installiert.",
|
||||
"emptyDesc": "Nutze die Tabs oben — Let's Encrypt issued vollautomatisch per HTTP-01-Challenge, oder lade ein eigenes PEM hoch. Bis dahin liefert HAProxy das Bootstrap-Self-Signed-Cert für alle Domains.",
|
||||
"certPem": "Zertifikat (PEM)",
|
||||
"chainPem": "Chain (PEM, optional)",
|
||||
"keyPem": "Private Key (PEM)"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.",
|
||||
"intro": "System-Information, Setup-Status und Admin-Account.",
|
||||
"systemInfo": "System",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"dbSize": "PostgreSQL-DB-Größe",
|
||||
"dbSizeTop": "Top-Tabellen",
|
||||
"upgradeStatusCardTitle": "Letzter Update-Versuch",
|
||||
"upgradeStatusStarted": "Gestartet",
|
||||
"upgradeStatusFinished": "Beendet",
|
||||
"upgradeStatusResult": "Ergebnis",
|
||||
"upgradeStatusState": "Status",
|
||||
"upgradeStatusOk": "Erfolgreich",
|
||||
"upgradeStatusShowLog": "Vollständiges Log anzeigen ({{n}} Zeilen)",
|
||||
"actionsCardTitle": "System-Aktionen",
|
||||
"actionsHint": "Manuelle Trigger für Operator-Tasks die sonst nur automatisch beim Speichern in den jeweiligen Seiten passieren. Sinnvoll nach SSH-Eingriffen (z. B. /etc/edgeguard/tls/ manuell befüllt).",
|
||||
"haproxyReloadBtn": "HAProxy reload",
|
||||
"haproxyReloadOk": "HAProxy neu geladen.",
|
||||
"haproxyReloadFailed": "HAProxy-Reload fehlgeschlagen",
|
||||
"renderConfigsBtn": "Configs neu rendern (HAProxy)",
|
||||
"renderConfigsOk": "Configs neu gerendert + reloaded.",
|
||||
"renderConfigsFailed": "Config-Render fehlgeschlagen",
|
||||
"backupNowBtn": "Backup jetzt erstellen",
|
||||
"backupNowOk": "Backup ausgelöst — Status in der Backups-Seite verfolgen.",
|
||||
"backupNowFailed": "Backup-Trigger fehlgeschlagen",
|
||||
"serviceRestartCardTitle": "Dienste neu starten",
|
||||
"serviceRestartBtn": "Restart",
|
||||
"serviceRestartOk": "{{service}} wurde neu gestartet.",
|
||||
"serviceRestartFailed": "Neustart von {{service}} fehlgeschlagen",
|
||||
"serviceRestartHint": "Startet den Dienst via systemctl restart. edgeguard-api und PostgreSQL sind bewusst ausgeschlossen.",
|
||||
"setupInfo": "Setup",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"fqdn": "FQDN",
|
||||
"setupCompleted": "Setup abgeschlossen"
|
||||
"setupCompleted": "Setup abgeschlossen",
|
||||
"emailsCardTitle": "Kontakt-E-Mails",
|
||||
"adminEmailHint": "Wird beim Login als Admin-Identifier genutzt. Änderung erfordert Re-Login mit der neuen Adresse.",
|
||||
"acmeEmail": "ACME-E-Mail",
|
||||
"acmeEmailHint": "Wird Let's Encrypt als Account-Contact mitgegeben. Bestehende Zertifikate sind nicht betroffen — die nächste Renew-Operation registriert die neue Adresse.",
|
||||
"emailsSaved": "E-Mails aktualisiert.",
|
||||
"emailsFailed": "E-Mail-Update fehlgeschlagen",
|
||||
"maintenanceCardTitle": "Wartungs-Modus (gesamte Box)",
|
||||
"maintenanceOn": "Aktiv — alle Customer-Domains liefern 503. Mgmt-UI bleibt erreichbar.",
|
||||
"maintenanceOff": "Inaktiv — Customer-Traffic wird normal an die Backends geroutet.",
|
||||
"maintenanceMessage": "Wartungs-Meldung",
|
||||
"maintenanceMessagePlaceholder": "Service vorübergehend nicht verfügbar — wir sind in Kürze zurück.",
|
||||
"maintenanceMessageHint": "Wird im Response-Body der 503-Antworten an Endkunden mitgeschickt. Plain text, max. 500 Zeichen.",
|
||||
"maintenanceHint": "Schaltet HAProxy auf :443 in einen Default-503-Modus. Pro-Domain-Maintenance (Domains-Seite) wird hiervon übersteuert. Mgmt-UI auf :3443 ist NICHT betroffen.",
|
||||
"maintenanceSaved": "Wartungs-Modus aktualisiert.",
|
||||
"maintenanceFailed": "Wartungs-Modus-Toggle fehlgeschlagen",
|
||||
"maintenanceActiveTitle": "Wartungs-Modus aktiv",
|
||||
"maintenanceActiveDesc": "Alle Customer-Domains liefern aktuell 503. Mgmt-UI ist erreichbar — Customer-Traffic NICHT. Settings → Wartungs-Modus zum Deaktivieren.",
|
||||
"backupRetentionCardTitle": "Backup-Aufbewahrung",
|
||||
"backupRetentionUnit": "Backups",
|
||||
"backupRetentionDefault": "Default ({{n}} Backups) — pro Tag bei täglichem Schedule = {{n}} Tage History.",
|
||||
"backupRetentionCustom": "Custom — die letzten {{n}} erfolgreichen Backups werden behalten, ältere werden nach jedem Backup-Lauf gelöscht.",
|
||||
"backupRetentionHint": "0 = Default (14). 1-365 = Custom-Limit. Bedenke: jedes Backup ist ein voller pg_dump + Files-tar (typisch 50-500 MB). Bei /var-Disk-Druck eher reduzieren.",
|
||||
"backupRetentionSaved": "Backup-Retention aktualisiert.",
|
||||
"backupRetentionFailed": "Backup-Retention-Update fehlgeschlagen",
|
||||
"auditRetentionCardTitle": "Audit-Log-Aufbewahrung",
|
||||
"auditRetentionUnit": "Tage",
|
||||
"auditRetentionDefault": "Default ({{n}} Tage) — Audit-Einträge älter als {{n}} Tage werden täglich gepruned.",
|
||||
"auditRetentionCustom": "Custom — Audit-Einträge werden für {{n}} Tage behalten.",
|
||||
"auditRetentionHint": "0 = Default (90). 1-3650 (= 10 Jahre) für Compliance (SOX 7y = 2555, DSGVO meist <= 365). Cleanup läuft im Scheduler täglich.",
|
||||
"auditRetentionSaved": "Audit-Retention aktualisiert.",
|
||||
"auditRetentionFailed": "Audit-Retention-Update fehlgeschlagen",
|
||||
"autoUpdateCardTitle": "Automatische Updates",
|
||||
"autoUpdateOn": "Aktiviert — edgeguard-Pakete werden täglich automatisch installiert.",
|
||||
"autoUpdateOff": "Deaktiviert — Updates müssen manuell über den Banner installiert werden.",
|
||||
"autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
|
||||
"autoUpdateToggled": "Auto-Update-Einstellung gespeichert.",
|
||||
"autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen",
|
||||
"passwordCardTitle": "Admin-Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"newPasswordHint": "Mindestens 12 Zeichen. Wird mit bcrypt gehasht.",
|
||||
"confirmPassword": "Neues Passwort bestätigen",
|
||||
"changePasswordBtn": "Passwort ändern",
|
||||
"passwordChanged": "Passwort geändert.",
|
||||
"passwordChangeFailed": "Passwort-Änderung fehlgeschlagen",
|
||||
"passwordMismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"passwordMinLen": "Mindestens 12 Zeichen erforderlich."
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar: Version {{version}}",
|
||||
@@ -362,6 +561,8 @@
|
||||
"confirmTitle": "Update jetzt installieren?",
|
||||
"confirmDesc": "Pakete werden auf Version {{version}} aktualisiert. edgeguard-api + scheduler restarten (~2-5s), HAProxy/nft/WG/Squid/Unbound/Chrony laufen durch.",
|
||||
"checkNow": "Auf Updates prüfen",
|
||||
"checkNowHint": "Server-seitigen apt-Cache jetzt aktualisieren und nach neueren Versionen schauen.",
|
||||
"updateReady": "Update bereit: v{{version}}",
|
||||
"checkDone": "Update verfügbar",
|
||||
"noUpdate": "Keine neuen Updates",
|
||||
"checkFailed": "Update-Check fehlgeschlagen",
|
||||
@@ -404,6 +605,10 @@
|
||||
"editServer": "Server-Tunnel bearbeiten",
|
||||
"addClient": "Client-Tunnel hinzufügen",
|
||||
"editClient": "Client-Tunnel bearbeiten",
|
||||
"emptyServerTitle": "Noch keine WireGuard-Server-Tunnel.",
|
||||
"emptyServerDesc": "Server-Modus: diese Box hört auf einem UDP-Port und akzeptiert Peer-Verbindungen (z. B. Roadwarrior-User, Site-to-Site-Niederlassungen).",
|
||||
"emptyClientTitle": "Noch keine WireGuard-Client-Tunnel.",
|
||||
"emptyClientDesc": "Client-Modus: diese Box verbindet sich zu einem externen WireGuard-Server (z. B. HQ-Datacenter, Cloud-Anbindung).",
|
||||
"upstream": "Upstream-Peer",
|
||||
"deleteConfirm": "Tunnel {{name}} wirklich löschen? wg-quick wird gestoppt.",
|
||||
"keys": "Schlüssel",
|
||||
@@ -430,6 +635,8 @@
|
||||
"add": "Peer hinzufügen",
|
||||
"edit": "Peer bearbeiten",
|
||||
"deleteConfirm": "Peer {{name}} wirklich entfernen?",
|
||||
"emptyTitle": "Noch keine Peers in diesem Tunnel.",
|
||||
"emptyDesc": "Lege Peers an — pro Peer eine WireGuard-Identität (Public-Key + Allowed-IPs). Server-generierte Keys liefern dir Config-Download bzw. QR-Code für Mobile-Clients direkt mit.",
|
||||
"keys": "Schlüssel",
|
||||
"generateExtra": "Wenn an: Server erzeugt für diesen Peer ein Keypair und kann die Config / QR-Code ausliefern. Wenn aus: nur den Public-Key paste-en — keine Config-Download möglich.",
|
||||
"pskExtra": "Wenn an: Server generiert einen 32-Byte PSK für diesen Peer.",
|
||||
@@ -471,7 +678,15 @@
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Knoten"
|
||||
"nodes": "Knoten",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config-Drift erkannt",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
@@ -486,6 +701,15 @@
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Aktuelle Alerts",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"onboardingTitle": "Willkommen bei EdgeGuard",
|
||||
"onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:",
|
||||
"onboardingStep1": "Backend-Pool anlegen (App-Server hinter HAProxy)",
|
||||
"onboardingStep2": "Domain hinzufügen (FQDN, primary Backend zuweisen)",
|
||||
"onboardingStep3": "TLS-Zertifikat ausstellen (Let's Encrypt HTTP-01)",
|
||||
"servicesCard": {
|
||||
"title": "Service-Status (live, 10s)"
|
||||
},
|
||||
@@ -510,6 +734,17 @@
|
||||
"title": "Zeitserver (Chrony)",
|
||||
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",
|
||||
"tabs": { "pools": "Quellen", "settings": "Settings" },
|
||||
"statusCard": {
|
||||
"title": "Sync-Status (chronyc tracking)",
|
||||
"sync": "Synchronisiert",
|
||||
"synced": "Ja",
|
||||
"notSynced": "Nein",
|
||||
"source": "Quelle",
|
||||
"stratum": "Stratum",
|
||||
"offset": "Offset",
|
||||
"offsetHint": "Zeitdifferenz zur Referenzquelle. Werte > 100 ms sind ungewöhnlich — Netzwerkprobleme oder fehlkonfigurierte Quelle prüfen.",
|
||||
"loading": "Lade…"
|
||||
},
|
||||
"pool": {
|
||||
"kind": "Typ",
|
||||
"kindPool": "pool — DNS-Round-Robin (mehrere Server aus A-Records)",
|
||||
@@ -524,7 +759,9 @@
|
||||
"description": "Beschreibung",
|
||||
"add": "Quelle hinzufügen",
|
||||
"edit": "Quelle bearbeiten",
|
||||
"deleteConfirm": "NTP-Quelle {{addr}} wirklich löschen?"
|
||||
"deleteConfirm": "NTP-Quelle {{addr}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine NTP-Quellen.",
|
||||
"emptyDesc": "Ohne konfigurierte Pool/Server-Einträge nutzt chrony nur seine compiled-in Default-Pool (debian.pool.ntp.org). Setze eigene Pools für genauere Zeitabstimmung oder interne Stratum-Server."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Globale Chrony-Settings. Save reloaded chrony automatisch.",
|
||||
@@ -560,7 +797,9 @@
|
||||
"records": "Records …",
|
||||
"add": "Zone hinzufügen",
|
||||
"edit": "Zone bearbeiten",
|
||||
"deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?"
|
||||
"deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?",
|
||||
"emptyTitle": "Noch keine DNS-Zonen.",
|
||||
"emptyDesc": "Unbound forwarded standardmäßig alles an die Upstream-Resolver. Lege eine Zone an, um z. B. interne FQDNs (internal.example.com) lokal zu hosten oder einen Upstream-Stub für eine fremde Domain einzurichten."
|
||||
},
|
||||
"record": {
|
||||
"name": "Name",
|
||||
@@ -572,7 +811,9 @@
|
||||
"drawerTitle": "DNS-Records",
|
||||
"add": "Record hinzufügen",
|
||||
"edit": "Record bearbeiten",
|
||||
"deleteConfirm": "Record {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Record {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Records in dieser Zone.",
|
||||
"emptyDesc": "A/AAAA/CNAME/MX/TXT-Einträge. Bei local-Zone authoritativ; bei forward-Zone hat das hier keinen Effekt (Upstream gewinnt)."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Globale Resolver-Settings. Änderungen hier reloaden Unbound automatisch.",
|
||||
@@ -608,7 +849,9 @@
|
||||
"comment": "Kommentar",
|
||||
"add": "ACL hinzufügen",
|
||||
"edit": "ACL bearbeiten",
|
||||
"deleteConfirm": "ACL {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "ACL {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Forward-Proxy-ACLs.",
|
||||
"emptyDesc": "Default ohne ACLs: nur localnet (10/8, 172.16/12, 192.168/16) darf raus. Lege eine ACL an, um spezifische Domains/IPs/Ports gezielt zu erlauben oder zu blocken."
|
||||
},
|
||||
"common": {
|
||||
"yes": "Ja",
|
||||
@@ -629,7 +872,9 @@
|
||||
"add": "Hinzufügen",
|
||||
"download": "Download",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert"
|
||||
"copied": "Kopiert",
|
||||
"close": "Schließen",
|
||||
"refresh": "Aktualisieren"
|
||||
},
|
||||
"license": {
|
||||
"title": "Lizenz",
|
||||
@@ -678,6 +923,8 @@
|
||||
"addTitle": "Statische Route anlegen",
|
||||
"editTitle": "Statische Route bearbeiten",
|
||||
"empty": "Keine verwalteten Routen.",
|
||||
"emptyTitle": "Noch keine verwalteten Routen.",
|
||||
"emptyDesc": "Statische Routen die EdgeGuard beim Boot setzt (z. B. nach 10.0.5.0/24 via VPN-Gateway). Live-Routen oben sind read-only — was hier eingetragen wird, persistiert.",
|
||||
"confirmDelete": "Route nach {{dest}} wirklich löschen?",
|
||||
"refreshTooltip": "Live-Routen neu laden",
|
||||
"destExtra": "CIDR — z.B. 10.0.5.0/24 oder 0.0.0.0/0 für Default-Route.",
|
||||
@@ -711,6 +958,10 @@
|
||||
"testDone": "Test gesendet — {{ok}}/{{total}} Channels erfolgreich",
|
||||
"emptyChannels": "Keine Channels. Lege einen Webhook oder eine Email an.",
|
||||
"emptyEvents": "Noch keine Alarme — Triggers haben noch keinen Event gefeuert.",
|
||||
"emptyChannelsTitle": "Noch keine Alert-Channels.",
|
||||
"emptyChannelsDesc": "Ohne Channels werden gefeuerte Events nur in die Datenbank (Tab \"Events\") geschrieben — niemand wird benachrichtigt. Lege einen Webhook (Mattermost/Slack/Discord/Custom) oder eine SMTP-Email an.",
|
||||
"emptyEventsTitle": "Noch keine Alert-Events.",
|
||||
"emptyEventsDesc": "Triggers (Cert-Expiry, Backup-Fail, Cluster-Drift, License-Invalid, etc.) haben noch keinen Event gefeuert. Wenn sie feuern, landen sie hier und werden an die konfigurierten Channels zugestellt.",
|
||||
"noChannels": "kein Channel aktiv",
|
||||
"confirmDelete": "Channel {{name}} wirklich löschen?",
|
||||
"col": {
|
||||
@@ -766,6 +1017,12 @@
|
||||
"scopeTitle": "Was wird gesichert?",
|
||||
"scopeDesc": "DB-Dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Konfig-Dateien (haproxy.cfg, nft, …) sind aus der DB regenerierbar und werden NICHT mitgesichert.",
|
||||
"tabs": { "history": "Sicherungen", "remotes": "Off-Site-Ziele" },
|
||||
"statLastSuccess": "Letzte erfolgreiche Sicherung",
|
||||
"statTotal": "Erfolgreiche Sicherungen",
|
||||
"statSize": "Gesamtgröße",
|
||||
"statFails24h": "Fehlschläge (24 h)",
|
||||
"statHoursAgo": "vor {{n}} h",
|
||||
"statDaysAgo": "vor {{n}} Tagen",
|
||||
"runNow": "Backup jetzt erstellen",
|
||||
"created": "Backup erstellt: {{file}}",
|
||||
"failed": "Backup fehlgeschlagen",
|
||||
@@ -821,7 +1078,8 @@
|
||||
"filter": {
|
||||
"sources": "Quellen wählen (alle wenn leer)",
|
||||
"levels": "Level filtern",
|
||||
"grep": "Volltext-Suche"
|
||||
"grep": "Volltext-Suche",
|
||||
"reset": "Filter zurücksetzen"
|
||||
}
|
||||
},
|
||||
"fwlog": {
|
||||
@@ -861,5 +1119,34 @@
|
||||
"dst": "Ziel-IP",
|
||||
"rule": "Rule-ID"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit-Log",
|
||||
"intro": "Wer hat was wann geändert. Jede Mutation in der API (Domain anlegen, Backend deaktivieren, Cert ausstellen, …) ist hier nachvollziehbar.",
|
||||
"filter": {
|
||||
"actor": "Actor",
|
||||
"action": "Aktion",
|
||||
"subject": "Subjekt",
|
||||
"range": "Zeitraum",
|
||||
"search": "Suchen",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"col": {
|
||||
"time": "Zeit",
|
||||
"actor": "Actor",
|
||||
"action": "Aktion",
|
||||
"subject": "Subjekt",
|
||||
"detail": "Details"
|
||||
},
|
||||
"detailShow": "Details anzeigen",
|
||||
"empty": {
|
||||
"title": "Keine Treffer",
|
||||
"desc": "Mit diesen Filtern wurden keine Einträge gefunden. Filter ändern oder zurücksetzen."
|
||||
},
|
||||
"page": {
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"showing": "Zeile {{from}}–{{to}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit log",
|
||||
"backups": "Backups",
|
||||
"diagnostics": "Diagnostics",
|
||||
"alerts": "Alerts",
|
||||
@@ -44,7 +45,8 @@
|
||||
"addrObj": "Address objects",
|
||||
"addrGrp": "Address groups",
|
||||
"services": "Services",
|
||||
"svcGrp": "Service groups"
|
||||
"svcGrp": "Service groups",
|
||||
"system": "System rules"
|
||||
},
|
||||
"zone": {
|
||||
"name": "Name",
|
||||
@@ -55,31 +57,41 @@
|
||||
"namePattern": "Lowercase letters, digits, _ and -; must start with a letter, up to 32 chars.",
|
||||
"add": "Add zone",
|
||||
"edit": "Edit zone",
|
||||
"deleteConfirm": "Really delete zone {{name}}?"
|
||||
"deleteConfirm": "Really delete zone {{name}}?",
|
||||
"emptyTitle": "No custom firewall zones yet.",
|
||||
"emptyDesc": "Zones group interfaces (lan, wan, dmz). Built-in zones already exist; add custom ones for e.g. a separate dmz or a wg zone for VPN."
|
||||
},
|
||||
"ao": {
|
||||
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description",
|
||||
"add": "Add address object", "edit": "Edit address object",
|
||||
"deleteConfirm": "Really delete address object {{name}}?"
|
||||
"deleteConfirm": "Really delete address object {{name}}?",
|
||||
"emptyTitle": "No address objects yet.",
|
||||
"emptyDesc": "Reusable named IPs/networks/ranges for firewall + NAT rules (e.g. office-net = 10.0.0.0/24, mailbox-1 = 10.0.1.42)."
|
||||
},
|
||||
"ag": {
|
||||
"name": "Name", "members": "Members", "description": "Description",
|
||||
"add": "Add address group", "edit": "Edit address group",
|
||||
"selectMembers": "Select address objects",
|
||||
"deleteConfirm": "Really delete address group {{name}}?"
|
||||
"deleteConfirm": "Really delete address group {{name}}?",
|
||||
"emptyTitle": "No address groups yet.",
|
||||
"emptyDesc": "Bundle multiple address objects into a group (e.g. office-locations = [hq-net, branch-1-net, branch-2-net]) — rules then reference one group."
|
||||
},
|
||||
"svc": {
|
||||
"name": "Name", "proto": "Protocol", "ports": "Ports",
|
||||
"portStart": "Port (start)", "portEnd": "Port (end)",
|
||||
"description": "Description", "builtinHint": "Built-in — not editable",
|
||||
"add": "Add service", "edit": "Edit service",
|
||||
"deleteConfirm": "Really delete service {{name}}?"
|
||||
"deleteConfirm": "Really delete service {{name}}?",
|
||||
"emptyTitle": "No custom services yet.",
|
||||
"emptyDesc": "Built-ins (HTTP, HTTPS, SSH, …) already exist. Add app-specific ports (e.g. mailcow-imaps tcp/993) to make rules easier to read."
|
||||
},
|
||||
"sg": {
|
||||
"name": "Name", "members": "Members", "description": "Description",
|
||||
"add": "Add service group", "edit": "Edit service group",
|
||||
"selectMembers": "Select services",
|
||||
"deleteConfirm": "Really delete service group {{name}}?"
|
||||
"deleteConfirm": "Really delete service group {{name}}?",
|
||||
"emptyTitle": "No service groups yet.",
|
||||
"emptyDesc": "Bundle multiple services into a group (e.g. web-stack = [HTTP, HTTPS, HTTP/3]) — one rule with the group replaces three rules per service."
|
||||
},
|
||||
"rule": {
|
||||
"name": "Name", "priority": "Priority", "enabled": "Enabled", "log": "Log",
|
||||
@@ -90,7 +102,9 @@
|
||||
"serviceKind": "Service kind", "serviceGroup": "Service group",
|
||||
"comment": "Comment",
|
||||
"add": "Add rule", "edit": "Edit rule",
|
||||
"deleteConfirm": "Really delete this rule?"
|
||||
"deleteConfirm": "Really delete this rule?",
|
||||
"emptyTitle": "No custom firewall rules yet.",
|
||||
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards."
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled",
|
||||
@@ -102,7 +116,9 @@
|
||||
"targetAddr": "Target address", "targetPortStart": "Target port (start)", "targetPortEnd": "Target port (end)",
|
||||
"comment": "Comment",
|
||||
"add": "Add NAT rule", "edit": "Edit NAT rule",
|
||||
"deleteConfirm": "Really delete this NAT rule?"
|
||||
"deleteConfirm": "Really delete this NAT rule?",
|
||||
"emptyTitle": "No NAT rules yet.",
|
||||
"emptyDesc": "DNAT (e.g. external :2030 → internal 10.10.20.12:22 for SSH to an internal host) or SNAT/MASQUERADE (internet access for an internal subnet via the box IP)."
|
||||
},
|
||||
"sys": {
|
||||
"title": "System rules (always active)",
|
||||
@@ -125,6 +141,8 @@
|
||||
"systemDiscovered": "System interfaces (read-only)",
|
||||
"addInterface": "Add interface",
|
||||
"editInterface": "Edit interface",
|
||||
"emptyTitle": "No managed interfaces yet.",
|
||||
"emptyDesc": "System interfaces (above) are detected read-only. Managed interfaces are what EdgeGuard creates itself — VLANs, bridges, bonds, GRE tunnels — and renders into /etc/network/interfaces.d.",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"parent": "Parent interface",
|
||||
@@ -152,6 +170,8 @@
|
||||
"managedTitle": "Managed addresses",
|
||||
"family": "Family",
|
||||
"addAddress": "Add address",
|
||||
"emptyTitle": "No managed IP addresses yet.",
|
||||
"emptyDesc": "Add your first IP — floating VIPs for HA failover or extra listen addresses for HAProxy/Squid/Unbound. Distro-owned IPs are shown above under \"Detected IPs\".",
|
||||
"editAddress": "Edit address",
|
||||
"interface": "Interface",
|
||||
"selectInterface": "Select interface",
|
||||
@@ -192,12 +212,19 @@
|
||||
"setup": {
|
||||
"title": "First-time setup",
|
||||
"intro": "Create the admin account, declare the public FQDN, and — optionally — paste a license key. Without one, a 30-day trial starts.",
|
||||
"preflightTitle": "Before you hit \"Finish setup\"",
|
||||
"preflightDesc": "The FQDN must already resolve to this box via DNS (A/AAAA record). Without DNS resolution the later ACME HTTP-01 challenge fails — your browser URL should already be this FQDN, otherwise things break at first Let's Encrypt issue.",
|
||||
"adminEmail": "Admin email",
|
||||
"adminEmailHint": "Login identifier for the management UI. NOT used for outbound mail (use the ACME email below or the SMTP settings inside the Alerts channel).",
|
||||
"adminPassword": "Admin password",
|
||||
"passwordRule": "At least 12 characters.",
|
||||
"fqdn": "Public FQDN",
|
||||
"fqdnHint": "Fully-qualified hostname of this box (e.g. eg.example.com). Becomes the Subject-CN of the self-signed bootstrap cert AND ends up in the ACME cert once Let's Encrypt has issued. Must resolve to the public IP via DNS.",
|
||||
"fqdnInvalid": "Doesn't look like a valid FQDN (at least label.tld).",
|
||||
"acmeEmail": "ACME / Let's Encrypt email",
|
||||
"acmeEmailHint": "Sent to Let's Encrypt as account contact (expiry warnings + LE compliance mail). Can be the same as the admin email, but doesn't have to be.",
|
||||
"licenseKey": "License key (optional)",
|
||||
"licenseKeyHint": "If present: 30-day trial is skipped, features unlock immediately. Can also be added later under License.",
|
||||
"submit": "Finish setup",
|
||||
"successTitle": "Setup complete",
|
||||
"successHint": "Redirecting you to sign-in."
|
||||
@@ -211,14 +238,64 @@
|
||||
"intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.",
|
||||
"addDomain": "Add domain",
|
||||
"editDomain": "Edit domain",
|
||||
"emptyTitle": "No domains yet.",
|
||||
"emptyDesc": "Add your first domain — HAProxy will then terminate TLS for that hostname and route to the chosen backend.",
|
||||
"name": "Name",
|
||||
"active": "Active",
|
||||
"primaryBackend": "Primary backend",
|
||||
"primaryBackendHint": "Catch-all backend for requests with no matching routing rule. Optional — leave empty if all traffic is routed via routing rules.",
|
||||
"selectBackend": "Select backend",
|
||||
"noBackend": "no backend",
|
||||
"quickBackendBtn": "+ new",
|
||||
"quickBackendBtnHint": "Create backend + first upstream server in one click — skips the trip to the Backends page.",
|
||||
"quickBackendTitle": "Quick-add backend",
|
||||
"quickBackendName": "Backend name",
|
||||
"quickBackendScheme": "Scheme",
|
||||
"quickBackendAddress": "Server address (first upstream)",
|
||||
"quickBackendPort": "Port",
|
||||
"quickBackendCreated": "Backend + server created and selected.",
|
||||
"quickBackendFailed": "Quick-add backend failed",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"tlsCert": "TLS cert",
|
||||
"tlsCertValid": "valid",
|
||||
"tlsCertExpiring": "{{days}}d left",
|
||||
"tlsCertExpired": "expired",
|
||||
"tlsCertError": "error",
|
||||
"tlsCertNone": "missing",
|
||||
"tlsCertNoneHint": "No own TLS cert for this domain — HAProxy serves the bootstrap self-signed cert. Issue one in the SSL tab.",
|
||||
"hstsMaxAge": "HSTS max-age (sec)",
|
||||
"hstsMaxAgeHint": "How long browsers cache the HTTPS-only mandate. Recommended: 31536000 (1 year).",
|
||||
"hstsSubdomains": "includeSubDomains",
|
||||
"hstsSubdomainsHint": "Extends HSTS to all subdomains. Enable only if every subdomain speaks HTTPS exclusively.",
|
||||
"hstsPreload": "preload",
|
||||
"hstsPreloadHint": "Sets the preload flag so the domain may be added to hstspreload.org. Requires max-age ≥ 31536000 and includeSubDomains enabled.",
|
||||
"maintenance": "Maintenance mode",
|
||||
"maintenanceHint": "On: every request to this domain receives 503 directly from HAProxy. Backends are not contacted.",
|
||||
"maintenanceMessage": "Maintenance message",
|
||||
"maintenanceMessagePlaceholder": "Service temporarily unavailable.",
|
||||
"wwwRedirect": "www redirect",
|
||||
"wwwRedirectHint": "Canonicalises the domain. \"to naked\": Name=example.com → www.example.com redirects to example.com. \"to www\": Name=www.example.com → example.com redirects to www.example.com.",
|
||||
"wwwRedirectNone": "No redirect",
|
||||
"wwwRedirectToNaked": "→ naked (no www)",
|
||||
"wwwRedirectToWWW": "→ www",
|
||||
"rateLimit": "Rate limit (per client IP)",
|
||||
"rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.",
|
||||
"maxBody": "Max request body",
|
||||
"maxBodyHint": "Cap on the Content-Length header. Larger requests get 413. Chunked bodies are not detected (HAProxy doesn't buffer by default). 0 = off.",
|
||||
"headersBtn": "Headers",
|
||||
"headersTitle": "Response headers — {{name}}",
|
||||
"headersHint": "These headers are set by HAProxy on every response for this domain (http-response set-header). Ordering via position.",
|
||||
"headersEmpty": "No custom headers configured yet.",
|
||||
"addHeader": "Add header",
|
||||
"editHeader": "Edit header",
|
||||
"headerName": "Name",
|
||||
"headerNameHint": "Letters, digits, hyphens only. Case-insensitive unique per domain.",
|
||||
"headerNamePattern": "Only a-z, A-Z, 0-9, '-'",
|
||||
"headerValue": "Value",
|
||||
"headerPosition": "Position",
|
||||
"headerDeleteConfirm": "Really delete header \"{{name}}\"?",
|
||||
"settingsSection": "HAProxy settings",
|
||||
"notes": "Notes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
@@ -229,11 +306,14 @@
|
||||
"title": "Backends",
|
||||
"intro": "Upstream pools (one backend = N servers). HAProxy balances load by the chosen algorithm; health-check path enables HTTP probes every 5s per server.",
|
||||
"addBackend": "Add backend pool",
|
||||
"emptyTitle": "No backend pools yet.",
|
||||
"emptyDesc": "Add your first pool — define upstream servers (e.g. app servers / mailbox nodes) so domains can route to them.",
|
||||
"editBackend": "Edit backend pool",
|
||||
"name": "Name",
|
||||
"scheme": "Scheme",
|
||||
"target": "Target",
|
||||
"healthCheck": "Health check path",
|
||||
"liveStatus": "HAProxy status",
|
||||
"active": "Active",
|
||||
"usedBy": "Used by",
|
||||
"noDomain": "no domain",
|
||||
@@ -271,6 +351,8 @@
|
||||
"title": "Routing rules",
|
||||
"intro": "Path-prefix → backend mapping per domain. Lowest priority wins; catch-all via domain.primary_backend.",
|
||||
"addRule": "Add rule",
|
||||
"emptyTitle": "No routing rules yet.",
|
||||
"emptyDesc": "Path-specific routing — e.g. domain.tld/api → Backend A, domain.tld/* → Backend B. Optional. Without rules HAProxy uses the primary backend from the domain.",
|
||||
"editRule": "Edit rule",
|
||||
"domain": "Domain",
|
||||
"pathPrefix": "Path prefix",
|
||||
@@ -313,8 +395,35 @@
|
||||
"configHash": "Config hash",
|
||||
"version": "Version",
|
||||
"lastSeen": "Last seen",
|
||||
"mgmtIp": "MGMT IP"
|
||||
}
|
||||
"mgmtIp": "MGMT IP",
|
||||
"load": "Load 1/5/15",
|
||||
"mem": "Memory",
|
||||
"disk": "Disk",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime",
|
||||
"fetchMs": "Fetch"
|
||||
},
|
||||
"loadTitle": "Per-node resources (mTLS aggregator)",
|
||||
"loadEmpty": "No node resources available — agent listener unreachable?",
|
||||
"certCardTitle": "Cluster TLS certificates",
|
||||
"certCALabel": "Cluster CA",
|
||||
"certPeerLabel": "Peer cert (this node)",
|
||||
"certExpiry": "Expires in",
|
||||
"renewSelfBtn": "Renew peer cert",
|
||||
"renewSelfConfirm": "Re-sign peer cert with the local CA (1 year)? edgeguard-api restart required after.",
|
||||
"certRenewedRestartHint": "Cert renewed — please run sudo systemctl restart edgeguard-api.",
|
||||
"certRenewFailed": "Cert renewal failed",
|
||||
"removePeerBtn": "Remove",
|
||||
"removePeerConfirmTitle": "Really remove peer from the cluster?",
|
||||
"removePeerConfirmDesc": "{{fqdn}} is deleted from ha_nodes. Firewall renderer drops its IP from the peer_ipv4 set. On the peer side edgeguard-api keeps running; manually stop it + delete cluster-tls for a full decommission.",
|
||||
"removePeerOk": "Peer removed.",
|
||||
"removePeerFailed": "Peer removal failed",
|
||||
"generateJoinToken": "Generate join token",
|
||||
"joinTokenTitle": "Cluster join token",
|
||||
"joinTokenOneShot": "Shown once, usable once",
|
||||
"joinTokenOneShotDesc": "Transfer the token securely now — it won't be shown again. Valid until {{expires}}. Server marks it as consumed when redeemed via cluster-join.",
|
||||
"joinTokenFailed": "Token generation failed",
|
||||
"joinCmdLabel": "Run on the new node:"
|
||||
},
|
||||
"ssl": {
|
||||
"title": "SSL certificates",
|
||||
@@ -337,23 +446,113 @@
|
||||
"issueButton": "Issue certificate",
|
||||
"uploadButton": "Upload",
|
||||
"issueSuccess": "Certificate issued + installed.",
|
||||
"renewBtn": "Renew",
|
||||
"renewConfirmTitle": "Renew certificate now?",
|
||||
"renewConfirmDesc": "Triggers an ACME HTTP-01 challenge for {{domain}}. Let's Encrypt has rate limits (50 issues/domain/week) — only use when needed.",
|
||||
"renewSuccess": "Certificate renewed + installed.",
|
||||
"renewFailed": "Renewal failed",
|
||||
"uploadSuccess": "Certificate uploaded + installed.",
|
||||
"deleteConfirm": "Delete certificate for {{domain}}? HAProxy falls back to the default cert for this domain.",
|
||||
"installedTitle": "Installed certificates",
|
||||
"lastRenewed": "Last renewed",
|
||||
"statTotal": "Certificates total",
|
||||
"statExpiring": "Expiring < 30 days",
|
||||
"statExpired": "Expired",
|
||||
"statErrors": "With errors",
|
||||
"relAgo": {
|
||||
"justNow": "just now",
|
||||
"minutes": "{{n}} min ago",
|
||||
"hours": "{{n}} h ago",
|
||||
"days": "{{n}} d ago"
|
||||
},
|
||||
"emptyTitle": "No certificates installed yet.",
|
||||
"emptyDesc": "Use the tabs above — Let's Encrypt issues automatically via HTTP-01 challenge, or upload your own PEM. Until then HAProxy serves the bootstrap self-signed cert for all domains.",
|
||||
"certPem": "Certificate (PEM)",
|
||||
"chainPem": "Chain (PEM, optional)",
|
||||
"keyPem": "Private key (PEM)"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"intro": "System information and setup status. Editable values come in a later release.",
|
||||
"intro": "System information, setup status and admin account.",
|
||||
"systemInfo": "System",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"dbSize": "PostgreSQL DB size",
|
||||
"dbSizeTop": "Top tables",
|
||||
"upgradeStatusCardTitle": "Last upgrade attempt",
|
||||
"upgradeStatusStarted": "Started",
|
||||
"upgradeStatusFinished": "Finished",
|
||||
"upgradeStatusResult": "Result",
|
||||
"upgradeStatusState": "State",
|
||||
"upgradeStatusOk": "Successful",
|
||||
"upgradeStatusShowLog": "Show full log ({{n}} lines)",
|
||||
"actionsCardTitle": "System actions",
|
||||
"actionsHint": "Manual triggers for operator tasks that normally happen automatically on save in the respective pages. Useful after SSH interventions (e.g. /etc/edgeguard/tls/ filled manually).",
|
||||
"haproxyReloadBtn": "HAProxy reload",
|
||||
"haproxyReloadOk": "HAProxy reloaded.",
|
||||
"haproxyReloadFailed": "HAProxy reload failed",
|
||||
"renderConfigsBtn": "Re-render configs (HAProxy)",
|
||||
"renderConfigsOk": "Configs re-rendered + reloaded.",
|
||||
"renderConfigsFailed": "Config render failed",
|
||||
"backupNowBtn": "Backup now",
|
||||
"serviceRestartCardTitle": "Restart services",
|
||||
"serviceRestartBtn": "Restart",
|
||||
"serviceRestartOk": "{{service}} restarted successfully.",
|
||||
"serviceRestartFailed": "Restart of {{service}} failed",
|
||||
"serviceRestartHint": "Restarts the service via systemctl restart. edgeguard-api and PostgreSQL are intentionally excluded.",
|
||||
"backupNowOk": "Backup triggered — watch status on the Backups page.",
|
||||
"backupNowFailed": "Backup trigger failed",
|
||||
"setupInfo": "Setup",
|
||||
"adminEmail": "Admin email",
|
||||
"fqdn": "FQDN",
|
||||
"setupCompleted": "Setup completed"
|
||||
"setupCompleted": "Setup completed",
|
||||
"emailsCardTitle": "Contact emails",
|
||||
"adminEmailHint": "Used as the admin login identifier. Change requires a fresh login with the new address.",
|
||||
"acmeEmail": "ACME email",
|
||||
"acmeEmailHint": "Sent to Let's Encrypt as account contact. Existing certs are not affected — the next renew op registers the new address.",
|
||||
"emailsSaved": "Emails updated.",
|
||||
"emailsFailed": "Email update failed",
|
||||
"maintenanceCardTitle": "Maintenance mode (whole box)",
|
||||
"maintenanceOn": "Active — all customer domains return 503. Mgmt UI stays reachable.",
|
||||
"maintenanceOff": "Inactive — customer traffic is routed normally to backends.",
|
||||
"maintenanceMessage": "Maintenance message",
|
||||
"maintenanceMessagePlaceholder": "Service temporarily unavailable — we'll be back shortly.",
|
||||
"maintenanceMessageHint": "Returned in the body of 503 responses to end users. Plain text, max 500 chars.",
|
||||
"maintenanceHint": "Switches HAProxy on :443 into default-503 mode. Per-domain maintenance (Domains page) is overridden by this. Mgmt UI on :3443 is NOT affected.",
|
||||
"maintenanceSaved": "Maintenance mode updated.",
|
||||
"maintenanceFailed": "Maintenance toggle failed",
|
||||
"maintenanceActiveTitle": "Maintenance mode active",
|
||||
"maintenanceActiveDesc": "All customer domains currently return 503. Mgmt UI is reachable — customer traffic is NOT. Settings → Maintenance mode to disable.",
|
||||
"backupRetentionCardTitle": "Backup retention",
|
||||
"backupRetentionUnit": "backups",
|
||||
"backupRetentionDefault": "Default ({{n}} backups) — with daily schedule = {{n}} days of history.",
|
||||
"backupRetentionCustom": "Custom — the last {{n}} successful backups are kept; older ones are pruned after each backup run.",
|
||||
"backupRetentionHint": "0 = default (14). 1-365 = custom limit. Each backup is a full pg_dump + files tar (typically 50-500 MB). Reduce when /var disk gets tight.",
|
||||
"backupRetentionSaved": "Backup retention updated.",
|
||||
"backupRetentionFailed": "Backup retention update failed",
|
||||
"auditRetentionCardTitle": "Audit log retention",
|
||||
"auditRetentionUnit": "days",
|
||||
"auditRetentionDefault": "Default ({{n}} days) — audit entries older than {{n}} days are pruned daily.",
|
||||
"auditRetentionCustom": "Custom — audit entries are kept for {{n}} days.",
|
||||
"auditRetentionHint": "0 = default (90). 1-3650 (= 10 years) for compliance (SOX 7y = 2555, GDPR usually <= 365). Cleanup runs daily in the scheduler.",
|
||||
"auditRetentionSaved": "Audit retention updated.",
|
||||
"auditRetentionFailed": "Audit retention update failed",
|
||||
"autoUpdateCardTitle": "Automatic updates",
|
||||
"autoUpdateOn": "Enabled — edgeguard packages install automatically every day.",
|
||||
"autoUpdateOff": "Disabled — install updates manually via the banner.",
|
||||
"autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
|
||||
"autoUpdateToggled": "Auto-update setting saved.",
|
||||
"autoUpdateFailed": "Auto-update toggle failed",
|
||||
"passwordCardTitle": "Change admin password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"newPasswordHint": "At least 12 characters. Stored as bcrypt hash.",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"changePasswordBtn": "Change password",
|
||||
"passwordChanged": "Password changed.",
|
||||
"passwordChangeFailed": "Password change failed",
|
||||
"passwordMismatch": "Passwords do not match.",
|
||||
"passwordMinLen": "Minimum 12 characters required."
|
||||
},
|
||||
"update": {
|
||||
"available": "Update available: version {{version}}",
|
||||
@@ -361,6 +560,8 @@
|
||||
"applyNow": "Install now",
|
||||
"confirmTitle": "Install update now?",
|
||||
"confirmDesc": "Packages will be updated to version {{version}}. edgeguard-api + scheduler restart (~2-5 s), HAProxy/nft/WG/Squid/Unbound/Chrony stay running.",
|
||||
"checkNowHint": "Refresh the server-side apt cache now and look for newer versions.",
|
||||
"updateReady": "Update ready: v{{version}}",
|
||||
"checkNow": "Check for updates",
|
||||
"checkDone": "Update available",
|
||||
"noUpdate": "No new updates",
|
||||
@@ -404,6 +605,10 @@
|
||||
"editServer": "Edit server tunnel",
|
||||
"addClient": "Add client tunnel",
|
||||
"editClient": "Edit client tunnel",
|
||||
"emptyServerTitle": "No WireGuard server tunnels yet.",
|
||||
"emptyServerDesc": "Server mode: this box listens on a UDP port and accepts peer connections (e.g. roadwarrior users, site-to-site branches).",
|
||||
"emptyClientTitle": "No WireGuard client tunnels yet.",
|
||||
"emptyClientDesc": "Client mode: this box connects to an external WireGuard server (e.g. HQ datacenter, cloud uplink).",
|
||||
"upstream": "Upstream peer",
|
||||
"deleteConfirm": "Really delete tunnel {{name}}? wg-quick will be stopped.",
|
||||
"keys": "Keys",
|
||||
@@ -430,6 +635,8 @@
|
||||
"add": "Add peer",
|
||||
"edit": "Edit peer",
|
||||
"deleteConfirm": "Really remove peer {{name}}?",
|
||||
"emptyTitle": "No peers in this tunnel yet.",
|
||||
"emptyDesc": "Add peers — each is a WireGuard identity (public key + allowed IPs). Server-generated keys give you config download / QR code for mobile clients in one go.",
|
||||
"keys": "Keys",
|
||||
"generateExtra": "If on: server generates a keypair for this peer and can hand out the config / QR. If off: paste the peer's public key only — no config download.",
|
||||
"pskExtra": "If on: server generates a 32-byte PSK for this peer.",
|
||||
@@ -471,7 +678,15 @@
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Nodes"
|
||||
"nodes": "Nodes",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config drift detected",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
@@ -486,6 +701,15 @@
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Recent alerts",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"onboardingTitle": "Welcome to EdgeGuard",
|
||||
"onboardingIntro": "Fresh box — here are the next steps to route customer traffic:",
|
||||
"onboardingStep1": "Create a backend pool (app servers behind HAProxy)",
|
||||
"onboardingStep2": "Add a domain (FQDN, assign primary backend)",
|
||||
"onboardingStep3": "Issue TLS certificate (Let's Encrypt HTTP-01)",
|
||||
"servicesCard": {
|
||||
"title": "Service status (live, 10s)"
|
||||
},
|
||||
@@ -510,6 +734,17 @@
|
||||
"title": "Time server (Chrony)",
|
||||
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",
|
||||
"tabs": { "pools": "Sources", "settings": "Settings" },
|
||||
"statusCard": {
|
||||
"title": "Sync status (chronyc tracking)",
|
||||
"sync": "Synchronized",
|
||||
"synced": "Yes",
|
||||
"notSynced": "No",
|
||||
"source": "Source",
|
||||
"stratum": "Stratum",
|
||||
"offset": "Offset",
|
||||
"offsetHint": "Time difference to reference source. Values > 100 ms are unusual — check network or misconfigured source.",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"pool": {
|
||||
"kind": "Type",
|
||||
"kindPool": "pool — DNS round-robin (multiple servers from A records)",
|
||||
@@ -524,7 +759,9 @@
|
||||
"description": "Description",
|
||||
"add": "Add source",
|
||||
"edit": "Edit source",
|
||||
"deleteConfirm": "Really delete NTP source {{addr}}?"
|
||||
"deleteConfirm": "Really delete NTP source {{addr}}?",
|
||||
"emptyTitle": "No NTP sources yet.",
|
||||
"emptyDesc": "Without configured pool/server entries chrony falls back to its compiled-in default pool (debian.pool.ntp.org). Set custom pools for better time sync or internal stratum servers."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Global chrony settings. Saves reload chrony automatically.",
|
||||
@@ -560,7 +797,9 @@
|
||||
"records": "Records …",
|
||||
"add": "Add zone",
|
||||
"edit": "Edit zone",
|
||||
"deleteConfirm": "Really delete zone {{name}} and all its records?"
|
||||
"deleteConfirm": "Really delete zone {{name}} and all its records?",
|
||||
"emptyTitle": "No DNS zones yet.",
|
||||
"emptyDesc": "Unbound forwards everything to upstream resolvers by default. Add a zone to host internal FQDNs (internal.example.com) locally or set up an upstream stub for a foreign domain."
|
||||
},
|
||||
"record": {
|
||||
"name": "Name",
|
||||
@@ -572,7 +811,9 @@
|
||||
"drawerTitle": "DNS records",
|
||||
"add": "Add record",
|
||||
"edit": "Edit record",
|
||||
"deleteConfirm": "Really delete record {{name}}?"
|
||||
"deleteConfirm": "Really delete record {{name}}?",
|
||||
"emptyTitle": "No records in this zone yet.",
|
||||
"emptyDesc": "A/AAAA/CNAME/MX/TXT entries. Authoritative on local zones; on forward zones records here have no effect (upstream wins)."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Global resolver settings. Saves reload Unbound automatically.",
|
||||
@@ -608,7 +849,9 @@
|
||||
"comment": "Comment",
|
||||
"add": "Add ACL",
|
||||
"edit": "Edit ACL",
|
||||
"deleteConfirm": "Really delete ACL {{name}}?"
|
||||
"deleteConfirm": "Really delete ACL {{name}}?",
|
||||
"emptyTitle": "No forward-proxy ACLs yet.",
|
||||
"emptyDesc": "Default with no ACLs: only localnet (10/8, 172.16/12, 192.168/16) is allowed out. Add an ACL to selectively allow or block specific domains/IPs/ports."
|
||||
},
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
@@ -629,7 +872,9 @@
|
||||
"add": "Add",
|
||||
"download": "Download",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"license": {
|
||||
"title": "License",
|
||||
@@ -678,6 +923,8 @@
|
||||
"addTitle": "Add static route",
|
||||
"editTitle": "Edit static route",
|
||||
"empty": "No managed routes yet.",
|
||||
"emptyTitle": "No managed routes yet.",
|
||||
"emptyDesc": "Static routes EdgeGuard installs on boot (e.g. to 10.0.5.0/24 via VPN gateway). Live routes above are read-only — what you add here persists.",
|
||||
"confirmDelete": "Really delete route to {{dest}}?",
|
||||
"refreshTooltip": "Reload live routes",
|
||||
"destExtra": "CIDR — e.g. 10.0.5.0/24 or 0.0.0.0/0 for the default route.",
|
||||
@@ -711,6 +958,10 @@
|
||||
"testDone": "Test sent — {{ok}}/{{total}} channels OK",
|
||||
"emptyChannels": "No channels. Add a webhook or an email.",
|
||||
"emptyEvents": "No alerts yet — triggers haven't fired any events.",
|
||||
"emptyChannelsTitle": "No alert channels yet.",
|
||||
"emptyChannelsDesc": "Without channels, fired events are only written to the database (Events tab) — nobody is notified. Add a webhook (Mattermost/Slack/Discord/custom) or an SMTP email.",
|
||||
"emptyEventsTitle": "No alert events yet.",
|
||||
"emptyEventsDesc": "Triggers (cert expiry, backup failure, cluster drift, license invalid, etc.) haven't fired any events yet. When they do, they land here and get delivered to the configured channels.",
|
||||
"noChannels": "no active channel",
|
||||
"confirmDelete": "Really delete channel {{name}}?",
|
||||
"col": {
|
||||
@@ -766,6 +1017,12 @@
|
||||
"scopeTitle": "What is backed up?",
|
||||
"scopeDesc": "DB dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Generated configs (haproxy.cfg, nft, …) are reproducible from the DB and are NOT included.",
|
||||
"tabs": { "history": "Backups", "remotes": "Off-site targets" },
|
||||
"statLastSuccess": "Last successful backup",
|
||||
"statTotal": "Successful backups",
|
||||
"statSize": "Total size",
|
||||
"statFails24h": "Failures (24 h)",
|
||||
"statHoursAgo": "{{n}} h ago",
|
||||
"statDaysAgo": "{{n}} days ago",
|
||||
"runNow": "Run backup now",
|
||||
"created": "Backup created: {{file}}",
|
||||
"failed": "Backup failed",
|
||||
@@ -821,7 +1078,8 @@
|
||||
"filter": {
|
||||
"sources": "Select sources (all if empty)",
|
||||
"levels": "Filter levels",
|
||||
"grep": "Full-text search"
|
||||
"grep": "Full-text search",
|
||||
"reset": "Reset filters"
|
||||
}
|
||||
},
|
||||
"fwlog": {
|
||||
@@ -861,5 +1119,34 @@
|
||||
"dst": "Dest IP",
|
||||
"rule": "Rule ID"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit log",
|
||||
"intro": "Who changed what, when. Every mutation through the API (domain create, backend disable, cert issue, …) is recorded here.",
|
||||
"filter": {
|
||||
"actor": "Actor",
|
||||
"action": "Action",
|
||||
"subject": "Subject",
|
||||
"range": "Time range",
|
||||
"search": "Search",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"col": {
|
||||
"time": "Time",
|
||||
"actor": "Actor",
|
||||
"action": "Action",
|
||||
"subject": "Subject",
|
||||
"detail": "Details"
|
||||
},
|
||||
"detailShow": "Show details",
|
||||
"empty": {
|
||||
"title": "No matches",
|
||||
"desc": "No entries matched these filters. Adjust or reset them."
|
||||
},
|
||||
"page": {
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"showing": "Row {{from}}–{{to}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
management-ui/src/lib/storageSchema.ts
Normal file
44
management-ui/src/lib/storageSchema.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Storage-Schema-Stamp. Verhindert die „blank page nach Update"-Klasse
|
||||
// von Bugs: wenn wir die Form von etwas das wir nach localStorage
|
||||
// oder sessionStorage schreiben ändern, könnte das vorhandene Objekt
|
||||
// auf dem Client nicht mehr zur neuen Code-Version passen → Render-
|
||||
// throw → blank #root.
|
||||
//
|
||||
// Lösung: ein einziger Versions-Key. Beim App-Boot prüfen ob die
|
||||
// gespeicherte Version stimmt — wenn nicht, alle bekannten Storage-
|
||||
// Keys wegwerfen und neu stampen. Der Operator muss sich danach
|
||||
// einmal neu einloggen, sieht aber nicht mehr blank.
|
||||
//
|
||||
// Bump SCHEMA_VERSION immer wenn:
|
||||
// - eine SessionUser-Felddefinition geändert wird
|
||||
// - der Format der Logs-Filter geändert wird
|
||||
// - ein neuer Key zu KEYS hinzukommt der bei Mismatch raus muss
|
||||
//
|
||||
// Reine Additionen die abwärtskompatibel parsen brauchen keinen Bump.
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
const STAMP_KEY = 'eg_storage_schema'
|
||||
|
||||
// Alle Keys die wir selbst nach localStorage/sessionStorage schreiben.
|
||||
// Drittanbieter-Keys (z.B. i18nextLng) wipen wir bewusst nicht — die
|
||||
// Library kennt ihr eigenes Format und repariert sich selbst.
|
||||
const SESSION_KEYS = ['eg_session']
|
||||
const LOCAL_KEYS = ['edgeguard.logs.filters']
|
||||
|
||||
export function ensureStorageSchema(): void {
|
||||
let stored: number | null = null
|
||||
try {
|
||||
const raw = localStorage.getItem(STAMP_KEY)
|
||||
if (raw) stored = parseInt(raw, 10)
|
||||
} catch { /* localStorage disabled — nichts zu tun */ }
|
||||
|
||||
if (stored === SCHEMA_VERSION) return
|
||||
|
||||
// Mismatch (oder erstmaliger Start) → unsere eigenen Keys wegwerfen.
|
||||
// Try/catch pro Operation: ein einzelner Quota-/SecurityError soll
|
||||
// den Cleanup nicht abbrechen, sonst bleiben halb-bereinigte Reste.
|
||||
for (const k of SESSION_KEYS) { try { sessionStorage.removeItem(k) } catch { /* ignore */ } }
|
||||
for (const k of LOCAL_KEYS) { try { localStorage.removeItem(k) } catch { /* ignore */ } }
|
||||
|
||||
try { localStorage.setItem(STAMP_KEY, String(SCHEMA_VERSION)) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ensureStorageSchema } from './lib/storageSchema'
|
||||
|
||||
// Vor allen anderen Imports die Storage prüfen — i18n und auth-store
|
||||
// lesen beim Modul-Init aus Storage, also muss der Cleanup davor
|
||||
// passieren wenn die Schema-Version nicht stimmt.
|
||||
ensureStorageSchema()
|
||||
|
||||
import './styles/enterprise.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
204
management-ui/src/pages/Audit/index.tsx
Normal file
204
management-ui/src/pages/Audit/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user