feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -152,7 +152,10 @@
|
||||
"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.",
|
||||
"logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben",
|
||||
"ruleDisabled": "Regel deaktiviert"
|
||||
"ruleDisabled": "Regel deaktiviert",
|
||||
"enabled": "Aktiv",
|
||||
"unnamed": "(kein Name)",
|
||||
"zeroHitHint": "Keine Treffer seit dem letzten Neustart — möglicherweise ungenutzte oder überlagerte Regel"
|
||||
},
|
||||
"kpi": {
|
||||
"policyRules": "Policy-Regeln",
|
||||
@@ -281,7 +284,12 @@
|
||||
"loggedInAs": "Angemeldet als",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"viewerBadge": "Nur lesen",
|
||||
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen."
|
||||
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
|
||||
"totp": {
|
||||
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
|
||||
"verify": "Code bestätigen",
|
||||
"invalidCode": "Ungültiger Code"
|
||||
}
|
||||
},
|
||||
"reset": {
|
||||
"title": "Admin-Passwort zurücksetzen",
|
||||
@@ -392,6 +400,17 @@
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
|
||||
},
|
||||
"vipCard": {
|
||||
"title": "VIP / VRRP",
|
||||
"noVips": "Keine VIPs konfiguriert",
|
||||
"keepalivedInactive": "keepalived läuft nicht",
|
||||
"state": {
|
||||
"MASTER": "MASTER",
|
||||
"BACKUP": "BACKUP",
|
||||
"FAULT": "FAULT",
|
||||
"UNKNOWN": "Unbekannt"
|
||||
}
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
@@ -399,9 +418,16 @@
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"networkServicesCard": {
|
||||
"title": "Netzwerk-Dienste",
|
||||
"configure": "Konfigurieren"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Aktuelle Alerts",
|
||||
"viewAll": "Alle anzeigen"
|
||||
"viewAll": "Alle anzeigen",
|
||||
"summary": "{{critical}} kritisch · {{warning}} Warnung",
|
||||
"summaryWarning": "{{warning}} Warnung",
|
||||
"summaryCritical": "{{critical}} kritisch"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
||||
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
||||
@@ -664,7 +690,18 @@
|
||||
"hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication",
|
||||
"hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
||||
"hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived",
|
||||
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)"
|
||||
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)",
|
||||
"splitBrainSection": "Split-Brain-Schutz (Dual-Path VRRP + Gateway-Tracking)",
|
||||
"hbInterface": "Heartbeat-Interface",
|
||||
"hbInterfaceHelp": "Zweites Interface für die VI_HB-Instanz — VRRP-Advertisements laufen hier unabhängig von VI_1. Leer lassen um zu deaktivieren.",
|
||||
"hbSrcIp": "Heartbeat-Quell-IP",
|
||||
"hbSrcIpHelp": "Eigene IP auf dem Heartbeat-Interface (unicast_src_ip für VI_HB).",
|
||||
"hbPeerIp": "Heartbeat-Peer-IP",
|
||||
"hbPeerIpHelp": "Peer-IP auf dem Heartbeat-Interface (unicast_peer für VI_HB).",
|
||||
"hbRouterId": "Heartbeat Router-ID",
|
||||
"hbRouterIdHelp": "VRRP virtual_router_id für VI_HB — muss sich von der Haupt-Router-ID unterscheiden. Standard: 52.",
|
||||
"gwCheckIp": "Gateway-Check-IP",
|
||||
"gwCheckIpHelp": "Upstream-Gateway-IP die alle 5 s angepingt wird. Nicht erreichbar → Priorität sinkt um 110 → Failover wird ausgelöst. Leer lassen um zu deaktivieren."
|
||||
},
|
||||
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
|
||||
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
|
||||
@@ -699,7 +736,27 @@
|
||||
"step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://<node-fqdn>:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.",
|
||||
"generateNewToken": "Neuen Token generieren",
|
||||
"setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen",
|
||||
"newNodeFqdnLabel": "FQDN des neuen Knotens"
|
||||
"newNodeFqdnLabel": "FQDN des neuen Knotens",
|
||||
"vipTest": {
|
||||
"cardTitle": "VIP-Schwenk Test",
|
||||
"cardDesc": "Verschiebt einen VIP temporär auf den Secondary um zu prüfen ob die Dienste korrekt antworten. Keepalived ist nicht beteiligt — reiner ip addr add/del Test.",
|
||||
"colAddress": "VIP-Adresse",
|
||||
"colInterface": "Interface",
|
||||
"colActiveOn": "Aktiv auf",
|
||||
"swingBtn": "→ Secondary",
|
||||
"restoreBtn": "← Primary",
|
||||
"swingOk": "VIP erfolgreich auf Secondary geschwenkt",
|
||||
"restoreOk": "VIP zurück auf Primary",
|
||||
"swingFailed": "VIP-Schwenk fehlgeschlagen",
|
||||
"restoreFailed": "VIP-Rückschwenk fehlgeschlagen",
|
||||
"noVips": "Keine VIPs konfiguriert (ip_addresses mit is_vip=true)",
|
||||
"steps": "Schritte",
|
||||
"stepOk": "OK",
|
||||
"stepFail": "Fehler",
|
||||
"confirmSwing": "{{addr}} auf Secondary verschieben?",
|
||||
"confirmRestore": "{{addr}} zurück auf Primary?",
|
||||
"unknown": "unbekannt"
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"title": "SSL-Zertifikate",
|
||||
@@ -1126,7 +1183,16 @@
|
||||
"flushCacheFailed": "Cache-Flush fehlgeschlagen",
|
||||
"upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)",
|
||||
"accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)",
|
||||
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein"
|
||||
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein",
|
||||
"cacheSection": "Cache",
|
||||
"prefetch": "Häufige Records vorausladen",
|
||||
"prefetchExtra": "Häufig abgefragte Records werden vor Ablauf der TTL neu aufgelöst — reduziert Latenz für bekannte Namen.",
|
||||
"serveExpired": "Abgelaufene Records ausliefern",
|
||||
"serveExpiredExtra": "Stale Cache-Einträge zurückgeben wenn Upstream-Resolver nicht erreichbar ist. Reduziert SERVFAIL bei Ausfällen.",
|
||||
"msgCacheSizeMB": "Message-Cache (MB)",
|
||||
"msgCacheSizeMBExtra": "RAM für DNS-Antwort-Cache (msg-cache-size). Standard 64 MB.",
|
||||
"rrsetCacheSizeMB": "RRset-Cache (MB)",
|
||||
"rrsetCacheSizeMBExtra": "RAM für Resource-Record-Cache (rrset-cache-size). Sollte ~2x Message-Cache sein. Standard 128 MB."
|
||||
}
|
||||
},
|
||||
"fwd": {
|
||||
@@ -1180,6 +1246,28 @@
|
||||
"dstdom_regex": "dstdom_regex — Domain-Regex",
|
||||
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
|
||||
"browser": "browser — User-Agent-Regex"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Proxy-Einstellungen",
|
||||
"listenAddresses": "Listen-Adressen",
|
||||
"listenAddressesExtra": "Komma-getrennte IPs auf denen Squid lauscht (z.B. 10.0.5.1, 10.0.20.1). Leer = alle Interfaces.",
|
||||
"listenPort": "Port",
|
||||
"listenPortExtra": "Standard: 3128.",
|
||||
"saveFailed": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"cacheSection": "Cache",
|
||||
"cacheMemMB": "RAM-Cache (MB)",
|
||||
"cacheMemMBExtra": "RAM den Squid für Caching nutzt (cache_mem). Standard 64 MB.",
|
||||
"cacheDirMB": "Disk-Cache (MB)",
|
||||
"cacheDirMBExtra": "Speicherplatz für den UFS-Cache. Standard 100 MB.",
|
||||
"maxObjSizeMB": "Max. Objektgröße (MB)",
|
||||
"maxObjSizeMBExtra": "Größtes Objekt das Squid cached. Größere Objekte werden direkt durchgeleitet. Standard 4 MB.",
|
||||
"timeoutSection": "Timeouts",
|
||||
"connectTimeout": "Verbindungs-Timeout (s)",
|
||||
"connectTimeoutExtra": "Sekunden, die Squid beim Verbindungsaufbau zum Upstream wartet.",
|
||||
"readTimeout": "Lese-Timeout (s)",
|
||||
"readTimeoutExtra": "Sekunden zwischen aufeinanderfolgenden Lesevorgängen vom Upstream.",
|
||||
"requestTimeout": "Anfrage-Timeout (s)",
|
||||
"requestTimeoutExtra": "Maximale Zeit für einen vollständigen Request/Response-Zyklus."
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1205,6 +1293,8 @@
|
||||
"retry": "Erneut versuchen",
|
||||
"close": "Schließen",
|
||||
"refresh": "Aktualisieren",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"up": "UP",
|
||||
"down": "DOWN",
|
||||
"relTime": {
|
||||
@@ -1542,7 +1632,24 @@
|
||||
"errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.",
|
||||
"cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.",
|
||||
"you": "Ich",
|
||||
"never": "Nie"
|
||||
"never": "Nie",
|
||||
"totp": {
|
||||
"on": "2FA",
|
||||
"off": "–",
|
||||
"setup": "2FA einrichten",
|
||||
"manage": "2FA verwalten",
|
||||
"disable": "2FA deaktivieren",
|
||||
"disableFor": "2FA für {{email}} deaktivieren",
|
||||
"enabled": "2FA wurde aktiviert",
|
||||
"disabled": "2FA wurde deaktiviert",
|
||||
"setupTitle": "Zwei-Faktor-Authentifizierung einrichten",
|
||||
"manageTitle": "Zwei-Faktor-Authentifizierung",
|
||||
"scanHint": "Scanne den QR-Code mit Google Authenticator, Authy oder einer kompatiblen App.",
|
||||
"enterCode": "Gib den 6-stelligen Code aus deiner Authenticator-App ein:",
|
||||
"confirm": "Bestätigen & aktivieren",
|
||||
"alreadyEnabled": "2FA ist für diesen Account aktiv.",
|
||||
"disableHint": "Klicke auf 'Deaktivieren' um 2FA für diesen Account zu entfernen."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit-Log",
|
||||
|
||||
@@ -152,7 +152,10 @@
|
||||
"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.",
|
||||
"logEnabled": "Logging active — matched packets are written to the firewall log",
|
||||
"ruleDisabled": "Rule disabled"
|
||||
"ruleDisabled": "Rule disabled",
|
||||
"enabled": "Active",
|
||||
"unnamed": "(unnamed)",
|
||||
"zeroHitHint": "No hits since last restart — possibly unused or shadowed rule"
|
||||
},
|
||||
"kpi": {
|
||||
"policyRules": "Policy Rules",
|
||||
@@ -281,7 +284,12 @@
|
||||
"loggedInAs": "Signed in as",
|
||||
"forgotPassword": "Forgot your password?",
|
||||
"viewerBadge": "Read-only",
|
||||
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role."
|
||||
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
|
||||
"totp": {
|
||||
"prompt": "Enter the 6-digit code from your authenticator app.",
|
||||
"verify": "Verify code",
|
||||
"invalidCode": "Invalid code"
|
||||
}
|
||||
},
|
||||
"reset": {
|
||||
"title": "Reset admin password",
|
||||
@@ -392,6 +400,17 @@
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} domains have a primary backend"
|
||||
},
|
||||
"vipCard": {
|
||||
"title": "VIP / VRRP",
|
||||
"noVips": "No VIPs configured",
|
||||
"keepalivedInactive": "keepalived not running",
|
||||
"state": {
|
||||
"MASTER": "MASTER",
|
||||
"BACKUP": "BACKUP",
|
||||
"FAULT": "FAULT",
|
||||
"UNKNOWN": "Unknown"
|
||||
}
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
@@ -399,9 +418,16 @@
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"networkServicesCard": {
|
||||
"title": "Network services",
|
||||
"configure": "Configure"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Recent alerts",
|
||||
"viewAll": "View all"
|
||||
"title": "Active alerts",
|
||||
"viewAll": "View all",
|
||||
"summary": "{{critical}} critical · {{warning}} warning",
|
||||
"summaryWarning": "{{warning}} warning",
|
||||
"summaryCritical": "{{critical}} critical"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
||||
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
||||
@@ -664,7 +690,18 @@
|
||||
"hintPrimary": "On primary: edgeguard-ctl cluster-init-replication",
|
||||
"hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
|
||||
"hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived",
|
||||
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)"
|
||||
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)",
|
||||
"splitBrainSection": "Split-brain protection (dual-path VRRP + gateway tracking)",
|
||||
"hbInterface": "Heartbeat interface",
|
||||
"hbInterfaceHelp": "Second interface for VI_HB instance — VRRP advertisements run here independently of VI_1. Leave empty to disable.",
|
||||
"hbSrcIp": "Heartbeat source IP",
|
||||
"hbSrcIpHelp": "Own IP on the heartbeat interface (unicast_src_ip for VI_HB).",
|
||||
"hbPeerIp": "Heartbeat peer IP",
|
||||
"hbPeerIpHelp": "Peer IP on the heartbeat interface (unicast_peer for VI_HB).",
|
||||
"hbRouterId": "Heartbeat router ID",
|
||||
"hbRouterIdHelp": "VRRP virtual_router_id for VI_HB — must differ from main Router ID. Default: 52.",
|
||||
"gwCheckIp": "Gateway check IP",
|
||||
"gwCheckIpHelp": "Upstream gateway IP to ping every 5 s. If unreachable: priority drops by 110 → failover triggers. Leave empty to disable."
|
||||
},
|
||||
"loadTitle": "Per-node resources (mTLS aggregator)",
|
||||
"loadEmpty": "No node resources available — agent listener unreachable?",
|
||||
@@ -699,7 +736,27 @@
|
||||
"step3SetupDesc": "Open the setup wizard on the new node (https://<node-fqdn>:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.",
|
||||
"generateNewToken": "Generate new token",
|
||||
"setupWizardHint": "Open the setup wizard on the new node",
|
||||
"newNodeFqdnLabel": "New node FQDN"
|
||||
"newNodeFqdnLabel": "New node FQDN",
|
||||
"vipTest": {
|
||||
"cardTitle": "VIP failover test",
|
||||
"cardDesc": "Temporarily move a VIP to the secondary to test that services respond correctly. Keepalived is not involved — this is a raw ip addr add/del test.",
|
||||
"colAddress": "VIP address",
|
||||
"colInterface": "Interface",
|
||||
"colActiveOn": "Active on",
|
||||
"swingBtn": "→ Secondary",
|
||||
"restoreBtn": "← Primary",
|
||||
"swingOk": "VIP successfully moved to secondary",
|
||||
"restoreOk": "VIP restored to primary",
|
||||
"swingFailed": "VIP swing failed",
|
||||
"restoreFailed": "VIP restore failed",
|
||||
"noVips": "No VIPs configured (ip_addresses with is_vip=true)",
|
||||
"steps": "Steps",
|
||||
"stepOk": "OK",
|
||||
"stepFail": "Failed",
|
||||
"confirmSwing": "Move {{addr}} to secondary?",
|
||||
"confirmRestore": "Restore {{addr}} to primary?",
|
||||
"unknown": "unknown"
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"title": "SSL certificates",
|
||||
@@ -1126,7 +1183,16 @@
|
||||
"flushCacheFailed": "Flush failed",
|
||||
"upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)",
|
||||
"accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)",
|
||||
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL"
|
||||
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL",
|
||||
"cacheSection": "Cache",
|
||||
"prefetch": "Prefetch popular records",
|
||||
"prefetchExtra": "Re-fetch records before TTL expires if queried frequently — reduces latency for hot names.",
|
||||
"serveExpired": "Serve expired records",
|
||||
"serveExpiredExtra": "Return stale cache entries when upstream resolvers are unreachable. Reduces SERVFAIL during outages.",
|
||||
"msgCacheSizeMB": "Message cache (MB)",
|
||||
"msgCacheSizeMBExtra": "RAM for DNS response cache (msg-cache-size). Default 64 MB.",
|
||||
"rrsetCacheSizeMB": "RRset cache (MB)",
|
||||
"rrsetCacheSizeMBExtra": "RAM for resource-record cache (rrset-cache-size). Should be ~2x message cache. Default 128 MB."
|
||||
}
|
||||
},
|
||||
"fwd": {
|
||||
@@ -1180,6 +1246,28 @@
|
||||
"dstdom_regex": "dstdom_regex — destination domain regex",
|
||||
"srcdom_regex": "srcdom_regex — source domain regex",
|
||||
"browser": "browser — User-Agent regex"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Proxy settings",
|
||||
"listenAddresses": "Listen addresses",
|
||||
"listenAddressesExtra": "Comma-separated IPs Squid listens on (e.g. 10.0.5.1, 10.0.20.1). Leave empty to listen on all interfaces.",
|
||||
"listenPort": "Port",
|
||||
"listenPortExtra": "Default: 3128.",
|
||||
"saveFailed": "Settings could not be saved.",
|
||||
"cacheSection": "Cache",
|
||||
"cacheMemMB": "In-memory cache (MB)",
|
||||
"cacheMemMBExtra": "RAM used by Squid for caching (cache_mem). Default 64 MB.",
|
||||
"cacheDirMB": "Disk cache (MB)",
|
||||
"cacheDirMBExtra": "Disk space for the UFS cache. Default 100 MB.",
|
||||
"maxObjSizeMB": "Max. object size (MB)",
|
||||
"maxObjSizeMBExtra": "Largest object Squid will cache. Objects above this are fetched fresh. Default 4 MB.",
|
||||
"timeoutSection": "Timeouts",
|
||||
"connectTimeout": "Connect timeout (s)",
|
||||
"connectTimeoutExtra": "Seconds to wait when opening a connection to the upstream server.",
|
||||
"readTimeout": "Read timeout (s)",
|
||||
"readTimeoutExtra": "Seconds Squid waits between consecutive reads from the upstream.",
|
||||
"requestTimeout": "Request timeout (s)",
|
||||
"requestTimeoutExtra": "Maximum time for a complete request/response cycle."
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1205,6 +1293,8 @@
|
||||
"retry": "Retry",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"up": "UP",
|
||||
"down": "DOWN",
|
||||
"relTime": {
|
||||
@@ -1542,7 +1632,24 @@
|
||||
"errorEmailTaken": "This email address is already in use.",
|
||||
"cannotDeleteSelf": "You cannot delete your own account.",
|
||||
"you": "You",
|
||||
"never": "Never"
|
||||
"never": "Never",
|
||||
"totp": {
|
||||
"on": "2FA",
|
||||
"off": "–",
|
||||
"setup": "Set up 2FA",
|
||||
"manage": "Manage 2FA",
|
||||
"disable": "Disable 2FA",
|
||||
"disableFor": "Disable 2FA for {{email}}",
|
||||
"enabled": "2FA has been enabled",
|
||||
"disabled": "2FA has been disabled",
|
||||
"setupTitle": "Set up two-factor authentication",
|
||||
"manageTitle": "Two-factor authentication",
|
||||
"scanHint": "Scan the QR code with Google Authenticator, Authy, or any compatible app.",
|
||||
"enterCode": "Enter the 6-digit code from your authenticator app:",
|
||||
"confirm": "Confirm & activate",
|
||||
"alreadyEnabled": "2FA is active for this account.",
|
||||
"disableHint": "Click 'Disable 2FA' to remove two-factor authentication from this account."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit log",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -87,6 +87,28 @@ interface CertStatus {
|
||||
peer?: CertInfo
|
||||
}
|
||||
|
||||
interface VIPInfo {
|
||||
id: number
|
||||
address: string
|
||||
prefix: number
|
||||
device: string
|
||||
}
|
||||
|
||||
interface VIPStatusEntry {
|
||||
vip: VIPInfo
|
||||
active_on: string[]
|
||||
}
|
||||
|
||||
interface VIPTestStep {
|
||||
step: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface VIPTestResult {
|
||||
steps: VIPTestStep[]
|
||||
}
|
||||
|
||||
function statusTag(s: HANode['status'], t: (k: string) => string) {
|
||||
switch (s) {
|
||||
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
|
||||
@@ -272,6 +294,42 @@ export default function ClusterPage() {
|
||||
onError: () => message.error(t('cluster.joinTokenFailed')),
|
||||
})
|
||||
|
||||
const isClusterMode = data?.mode === 'cluster'
|
||||
|
||||
const vipStatusQuery = useQuery({
|
||||
queryKey: ['cluster', 'vip-status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/vip-status')
|
||||
const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null
|
||||
return payload?.vips ?? []
|
||||
},
|
||||
enabled: isClusterMode,
|
||||
refetchInterval: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null)
|
||||
|
||||
const vipSwing = useMutation({
|
||||
mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => {
|
||||
const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action })
|
||||
return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null
|
||||
},
|
||||
onSuccess: (result, { id, action }) => {
|
||||
if (result) setVipTestResult({ id, steps: result.steps })
|
||||
const allOk = result?.steps.every(s => s.ok) ?? false
|
||||
if (allOk) {
|
||||
const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk'
|
||||
void message.success(t(key))
|
||||
} else {
|
||||
const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed'
|
||||
void message.error(t(key))
|
||||
}
|
||||
void vipStatusQuery.refetch()
|
||||
},
|
||||
onError: (e: Error) => void message.error(e.message),
|
||||
})
|
||||
|
||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
||||
|
||||
const peerColumns: ColumnsType<HANode> = [
|
||||
@@ -641,6 +699,134 @@ export default function ClusterPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── VIP-Schwenk Test ─────────────────────────────────── */}
|
||||
{isClusterMode && (
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><SwapOutlined />{t('cluster.vipTest.cardTitle')}</Space>}
|
||||
className="mb-16"
|
||||
extra={
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => void vipStatusQuery.refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('cluster.vipTest.cardDesc')}
|
||||
className="mb-12"
|
||||
/>
|
||||
{vipStatusQuery.isLoading ? (
|
||||
<Spin />
|
||||
) : (vipStatusQuery.data?.length ?? 0) === 0 ? (
|
||||
<Text type="secondary">{t('cluster.vipTest.noVips')}</Text>
|
||||
) : (
|
||||
<Table<VIPStatusEntry>
|
||||
size="small"
|
||||
rowKey={r => String(r.vip.id)}
|
||||
dataSource={vipStatusQuery.data ?? []}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowRender: r => {
|
||||
const res = vipTestResult?.id === r.vip.id ? vipTestResult : null
|
||||
if (!res) return null
|
||||
return (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={res.steps}
|
||||
renderItem={s => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<Tag color={s.ok ? 'green' : 'red'}>{s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')}</Tag>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.step}</Text>
|
||||
{s.message && <Text type="danger" style={{ fontSize: 12 }}>{s.message}</Text>}
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
rowExpandable: r => vipTestResult?.id === r.vip.id,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: t('cluster.vipTest.colAddress'),
|
||||
key: 'address',
|
||||
render: (_, r) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>{r.vip.address}/{r.vip.prefix}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('cluster.vipTest.colInterface'),
|
||||
key: 'device',
|
||||
width: 120,
|
||||
render: (_, r) => <Tag>{r.vip.device}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.vipTest.colActiveOn'),
|
||||
key: 'activeOn',
|
||||
render: (_, r) => {
|
||||
if (!r.active_on || r.active_on.length === 0) {
|
||||
return <Tag color="red">{t('cluster.vipTest.unknown')}</Tag>
|
||||
}
|
||||
return (
|
||||
<Space size={4}>
|
||||
{r.active_on.map(fqdn => <Tag key={fqdn} color="green">{fqdn}</Tag>)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_, r) => {
|
||||
const localFqdn = data?.local_node?.fqdn
|
||||
const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false
|
||||
const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false
|
||||
const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id
|
||||
return (
|
||||
<Space size={4}>
|
||||
{!isViewer && !onPeer && (
|
||||
<Popconfirm
|
||||
title={t('cluster.vipTest.confirmSwing', { addr: r.vip.address })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })}
|
||||
>
|
||||
<Button size="small" loading={loading && onLocal}>
|
||||
{t('cluster.vipTest.swingBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{!isViewer && onPeer && (
|
||||
<Popconfirm
|
||||
title={t('cluster.vipTest.confirmRestore', { addr: r.vip.address })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'restore' })}
|
||||
>
|
||||
<Button size="small" type="primary" loading={loading}>
|
||||
{t('cluster.vipTest.restoreBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{isViewer && (
|
||||
<Tooltip title={t('auth.viewerBadge')}>
|
||||
<Button size="small" disabled>{t('cluster.vipTest.swingBtn')}</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Per-Node Resources ────────────────────────────────── */}
|
||||
<Card
|
||||
size="small"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -43,6 +43,10 @@ interface Settings {
|
||||
qname_minimisation: boolean
|
||||
cache_min_ttl: number
|
||||
cache_max_ttl: number
|
||||
prefetch: boolean
|
||||
serve_expired: boolean
|
||||
msg_cache_size_mb: number
|
||||
rrset_cache_size_mb: number
|
||||
}
|
||||
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
|
||||
@@ -464,6 +468,7 @@ function SettingsTab() {
|
||||
for (const i of sys ?? []) {
|
||||
if (i.ifname === 'lo') continue
|
||||
for (const a of i.addr_info ?? []) {
|
||||
if (a.local.startsWith('fe80:')) continue
|
||||
ipOptions.push({
|
||||
value: a.local,
|
||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||
@@ -477,7 +482,20 @@ function SettingsTab() {
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
} : undefined
|
||||
} : {
|
||||
listen_addresses: [],
|
||||
listen_port: 53,
|
||||
upstream_forwards: '1.1.1.1, 9.9.9.9',
|
||||
access_acl: '10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
|
||||
dnssec: false,
|
||||
qname_minimisation: true,
|
||||
cache_min_ttl: 60,
|
||||
cache_max_ttl: 86400,
|
||||
prefetch: false,
|
||||
serve_expired: false,
|
||||
msg_cache_size_mb: 64,
|
||||
rrset_cache_size_mb: 128,
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: SettingsForm) => {
|
||||
@@ -566,9 +584,19 @@ function SettingsTab() {
|
||||
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Form.Item label={t('dns.settings.prefetch')} name="prefetch" valuePropName="checked"
|
||||
extra={t('dns.settings.prefetchExtra')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dns.settings.serveExpired')} name="serve_expired" valuePropName="checked"
|
||||
extra={t('dns.settings.serveExpiredExtra')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider plain>{t('dns.settings.cacheSection')}</Divider>
|
||||
<Space wrap>
|
||||
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
|
||||
<InputNumber min={0} style={{ width: 120 }} />
|
||||
<InputNumber min={0} style={{ width: 130 }} addonAfter="s" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('dns.settings.cacheMax')}
|
||||
@@ -586,7 +614,15 @@ function SettingsTab() {
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<InputNumber min={60} style={{ width: 120 }} />
|
||||
<InputNumber min={60} style={{ width: 130 }} addonAfter="s" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dns.settings.msgCacheSizeMB')} name="msg_cache_size_mb"
|
||||
extra={t('dns.settings.msgCacheSizeMBExtra')}>
|
||||
<InputNumber min={8} max={4096} style={{ width: 130 }} addonAfter="MB" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('dns.settings.rrsetCacheSizeMB')} name="rrset_cache_size_mb"
|
||||
extra={t('dns.settings.rrsetCacheSizeMBExtra')}>
|
||||
<InputNumber min={16} max={8192} style={{ width: 130 }} addonAfter="MB" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -116,7 +116,17 @@ export default function NATRulesTab() {
|
||||
|
||||
const columns: ColumnsType<NATRule> = [
|
||||
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
|
||||
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
||||
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 100, render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
|
||||
{
|
||||
title: t('fw.nat.name'), key: 'name', width: 200,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name && <div style={{ fontWeight: 500 }}>{r.name}</div>}
|
||||
{r.comment && <div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{r.comment}</div>}
|
||||
{!r.name && !r.comment && <span style={{ color: 'var(--ant-color-text-quaternary)' }}>—</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('fw.nat.match'), key: 'match',
|
||||
render: (_, r) => (
|
||||
@@ -126,11 +136,11 @@ export default function NATRulesTab() {
|
||||
{r.proto && <Tag>{r.proto}</Tag>}
|
||||
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
|
||||
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
|
||||
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
|
||||
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) },
|
||||
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
|
||||
{
|
||||
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
||||
render: (v: boolean, row: NATRule) => (
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined,
|
||||
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
|
||||
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
const { Text } = Typography
|
||||
@@ -159,10 +160,27 @@ export default function RulesTab() {
|
||||
if (cidr) return cidr
|
||||
return 'any'
|
||||
}
|
||||
|
||||
// Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS"
|
||||
const autoDescription = (r: FwRule): string => {
|
||||
const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)
|
||||
const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)
|
||||
const svc = r.service_object_id ? svLabel(r.service_object_id)
|
||||
: r.service_group_id ? sgLabel(r.service_group_id)
|
||||
: 'any'
|
||||
return `${r.src_zone}/${src} → ${r.dst_zone}/${dst} · ${svc}`
|
||||
}
|
||||
|
||||
const renderService = (objID?: number | null, grpID?: number | null) => {
|
||||
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
|
||||
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}>⊂ {sgLabel(grpID)}</Tag>
|
||||
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
|
||||
return <span className="fw-addr-any">any</span>
|
||||
}
|
||||
|
||||
const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
|
||||
const label = renderAddrCompact(objID, grpID, cidr)
|
||||
if (label === 'any') return <span className="fw-addr-any">any</span>
|
||||
return <Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>{label}</Text>
|
||||
}
|
||||
|
||||
// ── Filter state ─────────────────────────────────────────────
|
||||
@@ -294,11 +312,7 @@ export default function RulesTab() {
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<ZoneBadge zone={r.src_zone} />
|
||||
{(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && (
|
||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
||||
{renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||
</Text>
|
||||
)}
|
||||
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -311,11 +325,7 @@ export default function RulesTab() {
|
||||
render: (_, r) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<ZoneBadge zone={r.dst_zone} />
|
||||
{(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && (
|
||||
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
|
||||
{renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||
</Text>
|
||||
)}
|
||||
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -327,11 +337,16 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.name'), key: 'name', ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
{r.name && <div style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>}
|
||||
{r.comment && (
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 1 }}>{r.comment}</div>
|
||||
)}
|
||||
{!r.name && !r.comment && <Text type="secondary" style={{ fontSize: 11 }}>—</Text>}
|
||||
{r.name
|
||||
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
||||
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>
|
||||
{t('fw.rule.unnamed')}
|
||||
</div>
|
||||
}
|
||||
{r.comment
|
||||
? <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>
|
||||
: <div className="fw-rule-desc">{autoDescription(r)}</div>
|
||||
}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -339,7 +354,13 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
|
||||
render: (_, r) => {
|
||||
const c = counterByID.get(r.id)
|
||||
if (!c || c.packets === 0) return <Text type="secondary" style={{ fontSize: 11 }}>—</Text>
|
||||
if (!c || c.packets === 0) return (
|
||||
<Tooltip title={r.enabled ? t('fw.rule.zeroHitHint') : undefined}>
|
||||
<span style={{ fontSize: 11, color: r.enabled ? '#FAAD14' : '#CBD5E1' }}>
|
||||
{r.enabled ? <><WarningOutlined style={{ fontSize: 10, marginRight: 2 }} />0</> : '—'}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
return (
|
||||
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
|
||||
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
|
||||
@@ -372,19 +393,19 @@ export default function RulesTab() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '', key: 'move', width: 60,
|
||||
title: '', key: 'move', width: 52,
|
||||
render: (_, row) => {
|
||||
const idx = sortedRules.findIndex(r => r.id === row.id)
|
||||
const swapping = swap.isPending
|
||||
return (
|
||||
<Space size={2}>
|
||||
<Space size={1} className="fw-row-actions">
|
||||
<Tooltip title={t('fw.rule.moveUp')}>
|
||||
<Button size="small" icon={<ArrowUpOutlined />}
|
||||
<Button type="text" size="small" icon={<ArrowUpOutlined />}
|
||||
disabled={isViewer || idx <= 0 || swapping}
|
||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('fw.rule.moveDown')}>
|
||||
<Button size="small" icon={<ArrowDownOutlined />}
|
||||
<Button type="text" size="small" icon={<ArrowDownOutlined />}
|
||||
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
|
||||
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
|
||||
</Tooltip>
|
||||
@@ -393,30 +414,27 @@ export default function RulesTab() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '', key: 'actions', width: 120,
|
||||
title: '', key: 'actions', width: 88,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Space size={0} className="fw-row-actions">
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
||||
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<EditOutlined />}
|
||||
disabled={isViewer}
|
||||
onClick={() => editFromRow(row)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
<Button type="text" size="small" icon={<CopyOutlined />}
|
||||
disabled={isViewer}
|
||||
loading={duplicate.isPending && duplicate.variables?.id === row.id}
|
||||
onClick={() => duplicate.mutate(row)}
|
||||
/>
|
||||
onClick={() => duplicate.mutate(row)} />
|
||||
</Tooltip>
|
||||
{isViewer ? (
|
||||
<Tooltip title={t('auth.viewerBadge')}>
|
||||
<Button size="small" danger disabled>{t('common.delete')}</Button>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Popconfirm title={t('fw.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
@@ -486,7 +504,14 @@ export default function RulesTab() {
|
||||
loading={isLoading}
|
||||
dataSource={filteredRules}
|
||||
columns={columns}
|
||||
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
|
||||
rowClassName={(row: FwRule) => {
|
||||
const c = counterByID.get(row.id)
|
||||
const zeroHit = row.enabled && (!c || c.packets === 0)
|
||||
return [
|
||||
!row.enabled ? 'fw-rule-row--disabled' : '',
|
||||
zeroHit ? 'fw-rule-row--zero-hit' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
}}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FireOutlined />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message,
|
||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
@@ -31,6 +31,18 @@ interface ACL {
|
||||
|
||||
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
||||
|
||||
interface ProxySettings {
|
||||
id: number
|
||||
listen_addresses: string
|
||||
listen_port: number
|
||||
cache_mem_mb: number
|
||||
cache_dir_mb: number
|
||||
max_obj_size_mb: number
|
||||
connect_timeout: number
|
||||
read_timeout: number
|
||||
request_timeout: number
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
acl_type: string
|
||||
@@ -98,6 +110,27 @@ export default function ForwardProxyPage() {
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['fwd-proxy', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/forward-proxy/settings')
|
||||
if (!isEnvelope(r.data)) return null
|
||||
return r.data.data as ProxySettings
|
||||
},
|
||||
})
|
||||
|
||||
const [settingsForm] = Form.useForm<ProxySettings>()
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: async (v: ProxySettings) => {
|
||||
await apiClient.put('/forward-proxy/settings', v)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] })
|
||||
},
|
||||
onError: () => message.error(t('fwd.settings.saveFailed')),
|
||||
})
|
||||
|
||||
const [editing, setEditing] = useState<ACL | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
@@ -284,6 +317,97 @@ export default function ForwardProxyPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-12"
|
||||
title={t('fwd.settings.title')}
|
||||
loading={settingsLoading}
|
||||
>
|
||||
<Form
|
||||
form={settingsForm}
|
||||
layout="vertical"
|
||||
initialValues={settings ?? {
|
||||
listen_addresses: '', listen_port: 3128,
|
||||
cache_mem_mb: 64, cache_dir_mb: 100, max_obj_size_mb: 4,
|
||||
connect_timeout: 60, read_timeout: 300, request_timeout: 300,
|
||||
}}
|
||||
key={settings?.id ?? 'loading'}
|
||||
onFinish={(v) => saveSettings.mutate(v)}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item
|
||||
label={t('fwd.settings.listenAddresses')}
|
||||
name="listen_addresses"
|
||||
extra={t('fwd.settings.listenAddressesExtra')}
|
||||
>
|
||||
<Input placeholder="10.0.5.1, 10.0.20.1" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item
|
||||
label={t('fwd.settings.listenPort')}
|
||||
name="listen_port"
|
||||
extra={t('fwd.settings.listenPortExtra')}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider plain>{t('fwd.settings.cacheSection')}</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.cacheMemMB')} name="cache_mem_mb" extra={t('fwd.settings.cacheMemMBExtra')}>
|
||||
<InputNumber min={16} max={8192} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.cacheDirMB')} name="cache_dir_mb" extra={t('fwd.settings.cacheDirMBExtra')}>
|
||||
<InputNumber min={100} max={102400} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.maxObjSizeMB')} name="max_obj_size_mb" extra={t('fwd.settings.maxObjSizeMBExtra')}>
|
||||
<InputNumber min={1} max={1024} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider plain>{t('fwd.settings.timeoutSection')}</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.connectTimeout')} name="connect_timeout" extra={t('fwd.settings.connectTimeoutExtra')}>
|
||||
<InputNumber min={5} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.readTimeout')} name="read_timeout" extra={t('fwd.settings.readTimeoutExtra')}>
|
||||
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item label={t('fwd.settings.requestTimeout')} name="request_timeout" extra={t('fwd.settings.requestTimeoutExtra')}>
|
||||
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saveSettings.isPending}
|
||||
disabled={isViewer}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
|
||||
@@ -193,26 +193,6 @@ export default function IPAddressesPage() {
|
||||
subtitle={t('ips.intro')}
|
||||
/>
|
||||
|
||||
<Card title={t('ips.systemDiscovered')} size="small" className="mb-12">
|
||||
{(sysAddrs ?? []).length === 0
|
||||
? <Typography.Text type="secondary">—</Typography.Text>
|
||||
: (
|
||||
<DataTable
|
||||
size="small"
|
||||
rowKey={(r) => `${r.ifname}-${r.address}`}
|
||||
dataSource={sysAddrs ?? []}
|
||||
|
||||
columns={[
|
||||
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
|
||||
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
@@ -240,6 +220,24 @@ export default function IPAddressesPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title={t('ips.systemDiscovered')} size="small" className="mt-12">
|
||||
{(sysAddrs ?? []).length === 0
|
||||
? <Typography.Text type="secondary">—</Typography.Text>
|
||||
: (
|
||||
<DataTable
|
||||
size="small"
|
||||
rowKey={(r) => `${r.ifname}-${r.address}`}
|
||||
dataSource={sysAddrs ?? []}
|
||||
columns={[
|
||||
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
|
||||
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Card>
|
||||
<Modal
|
||||
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { KeyOutlined } from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -17,20 +19,24 @@ interface LoginValues {
|
||||
export default function LoginPage({ onLogin }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [totpRequired, setTotpRequired] = useState(false)
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
|
||||
const onFinish = async (vals: LoginValues) => {
|
||||
try {
|
||||
const r = await apiClient.post('/auth/login', vals)
|
||||
if (isEnvelope(r.data)) {
|
||||
const u = r.data.data as SessionUser
|
||||
onLogin(u)
|
||||
navigate('/dashboard', { replace: true })
|
||||
if (!isEnvelope(r.data)) return
|
||||
const d = r.data.data as { totp_required?: boolean } & SessionUser
|
||||
if (d.totp_required) {
|
||||
setTotpRequired(true)
|
||||
return
|
||||
}
|
||||
onLogin(d)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; status?: number }
|
||||
if (err.status === 503) {
|
||||
// setup-mode → drop to wizard
|
||||
navigate('/setup', { replace: true })
|
||||
return
|
||||
}
|
||||
@@ -38,36 +44,73 @@ export default function LoginPage({ onLogin }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const onTOTPVerify = async () => {
|
||||
if (!totpCode || totpCode.length < 6) return
|
||||
setVerifying(true)
|
||||
try {
|
||||
const r = await apiClient.post('/auth/totp-verify', { code: totpCode })
|
||||
if (isEnvelope(r.data)) {
|
||||
onLogin(r.data.data as SessionUser)
|
||||
navigate('/dashboard', { replace: true })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err.message ?? t('auth.totp.invalidCode'))
|
||||
setTotpCode('')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||
<Card style={{ width: 400 }}>
|
||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
{t('app.title')}
|
||||
</Typography.Title>
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label={t('auth.email')}
|
||||
name="email"
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('auth.password')}
|
||||
name="password"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('auth.login')}
|
||||
|
||||
{!totpRequired ? (
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item label={t('auth.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('auth.password')} name="password" rules={[{ required: true }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>{t('auth.login')}</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : (
|
||||
<div>
|
||||
<Typography.Paragraph style={{ textAlign: 'center' }}>
|
||||
<KeyOutlined style={{ fontSize: 32, color: '#1677ff', marginBottom: 8 }} /><br />
|
||||
{t('auth.totp.prompt')}
|
||||
</Typography.Paragraph>
|
||||
<Input
|
||||
size="large"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
onPressEnter={onTOTPVerify}
|
||||
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="primary" block loading={verifying} onClick={onTOTPVerify}>
|
||||
{t('auth.totp.verify')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||
</div>
|
||||
<Button type="link" block style={{ marginTop: 8 }} onClick={() => { setTotpRequired(false); setTotpCode('') }}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!totpRequired && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -381,6 +381,7 @@ function SettingsTab() {
|
||||
for (const i of sys ?? []) {
|
||||
if (i.ifname === 'lo') continue
|
||||
for (const a of i.addr_info ?? []) {
|
||||
if (a.local.startsWith('fe80:')) continue
|
||||
ipOptions.push({
|
||||
value: a.local,
|
||||
label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
|
||||
|
||||
@@ -39,6 +39,11 @@ interface VIPSettingsValues {
|
||||
vip_interface?: string
|
||||
vip_auth_pass?: string
|
||||
vrrp_router_id?: number
|
||||
hb_interface?: string
|
||||
hb_src_ip?: string
|
||||
hb_peer_ip?: string
|
||||
hb_router_id?: number
|
||||
gw_check_ip?: string
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -839,6 +844,31 @@ export default function SettingsPage() {
|
||||
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
|
||||
<InputNumber min={1} max={255} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 12, marginTop: 8 }}>
|
||||
{t('cluster.vipCard.splitBrainSection')}
|
||||
</Typography.Text>
|
||||
<Form.Item label={t('cluster.vipCard.hbInterface')} name="hb_interface"
|
||||
extra={t('cluster.vipCard.hbInterfaceHelp')}>
|
||||
<Input placeholder="eth1" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.hbSrcIp')} name="hb_src_ip"
|
||||
extra={t('cluster.vipCard.hbSrcIpHelp')}>
|
||||
<Input placeholder="192.168.1.1" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.hbPeerIp')} name="hb_peer_ip"
|
||||
extra={t('cluster.vipCard.hbPeerIpHelp')}>
|
||||
<Input placeholder="192.168.1.2" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.hbRouterId')} name="hb_router_id"
|
||||
extra={t('cluster.vipCard.hbRouterIdHelp')}>
|
||||
<InputNumber min={1} max={255} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.gwCheckIp')} name="gw_check_ip"
|
||||
extra={t('cluster.vipCard.gwCheckIpHelp')}>
|
||||
<Input placeholder="89.163.205.1" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
|
||||
{!isViewer && (
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
Button, Form, Input, Modal, QRCode, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { KeyOutlined, LockOutlined, PlusOutlined, SafetyCertificateOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -21,6 +21,7 @@ interface User {
|
||||
email: string
|
||||
role: string
|
||||
active: boolean
|
||||
totp_enabled: boolean
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -45,9 +46,14 @@ export default function UsersPage() {
|
||||
|
||||
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
|
||||
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<User | null>(null)
|
||||
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<User | null>(null)
|
||||
const [pwTarget, setPwTarget] = useState<User | null>(null)
|
||||
const [totpTarget, setTotpTarget] = useState<User | null>(null)
|
||||
const [totpStep, setTotpStep] = useState(0)
|
||||
const [totpSecret, setTotpSecret] = useState('')
|
||||
const [totpUri, setTotpUri] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [createForm] = Form.useForm<CreateValues>()
|
||||
const [editForm] = Form.useForm<EditValues>()
|
||||
const [pwForm] = Form.useForm<PwValues>()
|
||||
@@ -80,6 +86,44 @@ export default function UsersPage() {
|
||||
onSuccess: invalidate,
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const disableTOTPMut = useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/users/${id}/totp`),
|
||||
onSuccess: () => { message.success(t('users.totp.disabled')); invalidate() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openTOTPSetup = async (row: User) => {
|
||||
setTotpTarget(row)
|
||||
setTotpStep(0)
|
||||
setTotpCode('')
|
||||
if (row.email === me?.actor) {
|
||||
// own account — generate secret via self-service endpoint
|
||||
try {
|
||||
const r = await apiClient.post('/auth/totp/setup')
|
||||
if (isEnvelope(r.data)) {
|
||||
const d = r.data.data as { secret: string; uri: string }
|
||||
setTotpSecret(d.secret)
|
||||
setTotpUri(d.uri)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error).message)
|
||||
setTotpTarget(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmTOTP = async () => {
|
||||
if (!totpTarget) return
|
||||
try {
|
||||
await apiClient.post('/auth/totp/confirm', { secret: totpSecret, code: totpCode })
|
||||
message.success(t('users.totp.enabled'))
|
||||
setTotpTarget(null)
|
||||
invalidate()
|
||||
} catch (e: unknown) {
|
||||
message.error((e as Error).message ?? t('auth.totp.invalidCode'))
|
||||
setTotpCode('')
|
||||
}
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: t('users.roleAdmin') },
|
||||
@@ -104,6 +148,12 @@ export default function UsersPage() {
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '2FA', key: 'totp', width: 70,
|
||||
render: (_, row) => row.totp_enabled
|
||||
? <Tag color="green" icon={<SafetyCertificateOutlined />}>{t('users.totp.on')}</Tag>
|
||||
: <Tag color="default">{t('users.totp.off')}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: User) => (
|
||||
@@ -127,7 +177,7 @@ export default function UsersPage() {
|
||||
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 120,
|
||||
title: t('common.actions'), key: 'actions', width: 160,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('users.setPassword')}>
|
||||
@@ -135,6 +185,22 @@ export default function UsersPage() {
|
||||
disabled={isViewer}
|
||||
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
|
||||
</Tooltip>
|
||||
{/* 2FA button: setup for own account, disable for others */}
|
||||
{row.email === me?.actor ? (
|
||||
<Tooltip title={row.totp_enabled ? t('users.totp.manage') : t('users.totp.setup')}>
|
||||
<Button type="text" size="small"
|
||||
icon={<LockOutlined style={{ color: row.totp_enabled ? '#52c41a' : undefined }} />}
|
||||
onClick={() => void openTOTPSetup(row)} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
row.totp_enabled && !isViewer && (
|
||||
<Tooltip title={t('users.totp.disableFor', { email: row.email })}>
|
||||
<Button type="text" size="small" danger icon={<LockOutlined />}
|
||||
loading={disableTOTPMut.isPending && disableTOTPMut.variables === row.id}
|
||||
onClick={() => disableTOTPMut.mutate(row.id)} />
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
@@ -150,13 +216,11 @@ export default function UsersPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const isSelfTOTP = totpTarget?.email === me?.actor
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<TeamOutlined />}
|
||||
title={t('users.title')}
|
||||
subtitle={t('users.intro')}
|
||||
/>
|
||||
<PageHeader icon={<TeamOutlined />} title={t('users.title')} subtitle={t('users.intro')} />
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
@@ -195,16 +259,10 @@ export default function UsersPage() {
|
||||
/>
|
||||
|
||||
{/* Create modal */}
|
||||
<Modal
|
||||
title={t('users.addUser')}
|
||||
open={creating}
|
||||
<Modal title={t('users.addUser')} open={creating}
|
||||
onCancel={() => { setCreating(false); createForm.resetFields() }}
|
||||
onOk={() => void createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={createForm} layout="vertical"
|
||||
onFinish={(v) => createMut.mutate(v)}>
|
||||
onOk={() => void createForm.submit()} confirmLoading={createMut.isPending} destroyOnHidden>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
<Input autoFocus autoComplete="off" />
|
||||
</Form.Item>
|
||||
@@ -223,14 +281,9 @@ export default function UsersPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Edit modal */}
|
||||
<Modal
|
||||
title={t('users.editUser')}
|
||||
open={editing !== null}
|
||||
<Modal title={t('users.editUser')} open={editing !== null}
|
||||
onCancel={() => { setEditing(null); editForm.resetFields() }}
|
||||
onOk={() => void editForm.submit()}
|
||||
confirmLoading={updateMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
onOk={() => void editForm.submit()} confirmLoading={updateMut.isPending} destroyOnHidden>
|
||||
<Form form={editForm} layout="vertical"
|
||||
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
|
||||
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
|
||||
@@ -246,14 +299,10 @@ export default function UsersPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Set password modal */}
|
||||
<Modal
|
||||
title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
||||
<Modal title={`${t('users.setPasswordTitle')} — ${pwTarget?.email ?? ''}`}
|
||||
open={pwTarget !== null}
|
||||
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
|
||||
onOk={() => void pwForm.submit()}
|
||||
confirmLoading={pwMut.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
onOk={() => void pwForm.submit()} confirmLoading={pwMut.isPending} destroyOnHidden>
|
||||
<Form form={pwForm} layout="vertical"
|
||||
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
|
||||
<Form.Item label={t('users.newPassword')} name="password"
|
||||
@@ -263,6 +312,61 @@ export default function UsersPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* TOTP setup modal (own account only) */}
|
||||
<Modal
|
||||
title={isSelfTOTP ? t('users.totp.setupTitle') : t('users.totp.manageTitle')}
|
||||
open={totpTarget !== null}
|
||||
onCancel={() => setTotpTarget(null)}
|
||||
footer={totpStep === 1
|
||||
? [
|
||||
<Button key="back" onClick={() => setTotpStep(0)}>{t('common.back')}</Button>,
|
||||
<Button key="confirm" type="primary" onClick={() => void confirmTOTP()}>{t('users.totp.confirm')}</Button>,
|
||||
]
|
||||
: [
|
||||
<Button key="cancel" onClick={() => setTotpTarget(null)}>{t('common.cancel')}</Button>,
|
||||
totpTarget?.totp_enabled
|
||||
? <Button key="disable" danger onClick={() => { if (totpTarget) { disableTOTPMut.mutate(totpTarget.id); setTotpTarget(null) } }}>{t('users.totp.disable')}</Button>
|
||||
: <Button key="next" type="primary" onClick={() => setTotpStep(1)}>{t('common.next')}</Button>,
|
||||
]
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
{totpStep === 0 && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{totpTarget?.totp_enabled ? (
|
||||
<>
|
||||
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
||||
<Typography.Paragraph>{t('users.totp.alreadyEnabled')}</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary">{t('users.totp.disableHint')}</Typography.Paragraph>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Paragraph>{t('users.totp.scanHint')}</Typography.Paragraph>
|
||||
{totpUri && <QRCode value={totpUri} size={200} style={{ margin: '0 auto 16px' }} />}
|
||||
<Typography.Paragraph type="secondary" copyable={{ text: totpSecret }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{totpSecret}
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{totpStep === 1 && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Typography.Paragraph>{t('users.totp.enterCode')}</Typography.Paragraph>
|
||||
<Input
|
||||
size="large"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
onPressEnter={() => void confirmTOTP()}
|
||||
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, width: 200 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2850,6 +2850,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.fw-rule-row--disabled td:first-child {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
/* Strikethrough on disabled rule name */
|
||||
.fw-rule-row--disabled .fw-rule-name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #94A3B8;
|
||||
}
|
||||
|
||||
/* Zero-hit rule: amber left border (potentially unused/shadowed policy) */
|
||||
.fw-rule-row--zero-hit td:first-child {
|
||||
border-left: 3px solid #FAAD14 !important;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
/* Enabled status dot */
|
||||
.fw-rule-dot {
|
||||
@@ -2869,6 +2880,31 @@ h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Action buttons: hidden by default, reveal on row hover */
|
||||
.fw-row-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.ant-table-row:hover .fw-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Natural-language rule description */
|
||||
.fw-rule-desc {
|
||||
font-size: 11px;
|
||||
color: #94A3B8;
|
||||
margin-top: 2px;
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* "any" address label */
|
||||
.fw-addr-any {
|
||||
font-size: 11px;
|
||||
color: #94A3B8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Filter bar */
|
||||
.fw-filter-bar {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user