feat(setup): Cluster-Node-Modus im Setup-Wizard + Install-Script-Port-Fix
Setup-Wizard zeigt jetzt beim ersten Aufruf eine Modusauswahl: - "Neuinstallation" → bisheriger Flow (Admin-Account + FQDN + ACME) - "Cluster-Knoten beitreten" → nur FQDN + ACME, kein Admin-Account; nach dem Submit werden die cluster-join-Befehle direkt angezeigt Backend: NodeRequest + Store.CompleteAsNode() + POST /setup/complete-node State: is_cluster_node Flag; login via PG-Replikation vom Primary Install-Script: Setup-URL zeigt jetzt korrekt :3443/setup statt /setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,52 +63,87 @@
|
||||
"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",
|
||||
"name": "Name",
|
||||
"kind": "Typ",
|
||||
"value": "Wert",
|
||||
"description": "Beschreibung",
|
||||
"add": "Adress-Objekt hinzufügen",
|
||||
"edit": "Adress-Objekt bearbeiten",
|
||||
"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",
|
||||
"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?",
|
||||
"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",
|
||||
"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?",
|
||||
"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",
|
||||
"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?",
|
||||
"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",
|
||||
"action": "Aktion", "src": "Quelle", "dst": "Ziel", "service": "Service",
|
||||
"srcZone": "Quell-Zone", "dstZone": "Ziel-Zone",
|
||||
"srcKind": "Quell-Typ", "dstKind": "Ziel-Typ",
|
||||
"object": "Adress-Objekt", "group": "Adress-Gruppe",
|
||||
"serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe",
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"enabled": "Aktiv",
|
||||
"log": "Logging",
|
||||
"action": "Aktion",
|
||||
"src": "Quelle",
|
||||
"dst": "Ziel",
|
||||
"service": "Service",
|
||||
"srcZone": "Quell-Zone",
|
||||
"dstZone": "Ziel-Zone",
|
||||
"srcKind": "Quell-Typ",
|
||||
"dstKind": "Ziel-Typ",
|
||||
"object": "Adress-Objekt",
|
||||
"group": "Adress-Gruppe",
|
||||
"serviceKind": "Service-Typ",
|
||||
"serviceGroup": "Service-Gruppe",
|
||||
"comment": "Kommentar",
|
||||
"hits": "Hits",
|
||||
"add": "Regel hinzufügen", "edit": "Regel bearbeiten",
|
||||
"add": "Regel hinzufügen",
|
||||
"edit": "Regel bearbeiten",
|
||||
"deleteConfirm": "Diese Regel wirklich löschen?",
|
||||
"namePlaceholder": "z.B. SSH von Office erlauben",
|
||||
"cidr": "CIDR",
|
||||
"actions": { "accept": "ACCEPT", "drop": "DROP", "reject": "REJECT" },
|
||||
"kinds": { "any": "Beliebig", "object": "Objekt", "group": "Gruppe", "cidr": "CIDR" },
|
||||
"actions": {
|
||||
"accept": "ACCEPT",
|
||||
"drop": "DROP",
|
||||
"reject": "REJECT"
|
||||
},
|
||||
"kinds": {
|
||||
"any": "Beliebig",
|
||||
"object": "Objekt",
|
||||
"group": "Gruppe",
|
||||
"cidr": "CIDR"
|
||||
},
|
||||
"duplicate": "Regel duplizieren",
|
||||
"duplicated": "Regel dupliziert (deaktiviert, Priorität +1).",
|
||||
"cidrInvalid": "Gültiges CIDR eingeben (z.B. 10.0.0.0/24)",
|
||||
@@ -139,16 +174,27 @@
|
||||
"noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen."
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv",
|
||||
"match": "Match", "target": "Ziel",
|
||||
"inZone": "Eingangs-Zone", "outZone": "Ausgangs-Zone", "proto": "Protokoll",
|
||||
"matchSrcCidr": "Source-CIDR (Match)", "matchDstCidr": "Dest-CIDR (Match)",
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"kind": "Typ",
|
||||
"enabled": "Aktiv",
|
||||
"match": "Match",
|
||||
"target": "Ziel",
|
||||
"inZone": "Eingangs-Zone",
|
||||
"outZone": "Ausgangs-Zone",
|
||||
"proto": "Protokoll",
|
||||
"matchSrcCidr": "Source-CIDR (Match)",
|
||||
"matchDstCidr": "Dest-CIDR (Match)",
|
||||
"matchDstCidrHint": "leer = jede dest-IP (z.B. öffentliche IP der Box)",
|
||||
"dportStart": "Port (Start)", "dportEnd": "Port (Ende)",
|
||||
"dportStart": "Port (Start)",
|
||||
"dportEnd": "Port (Ende)",
|
||||
"portRangeError": "End-Port muss ≥ Start-Port sein",
|
||||
"targetAddr": "Ziel-Adresse", "targetPortStart": "Ziel-Port (Start)", "targetPortEnd": "Ziel-Port (Ende)",
|
||||
"targetAddr": "Ziel-Adresse",
|
||||
"targetPortStart": "Ziel-Port (Start)",
|
||||
"targetPortEnd": "Ziel-Port (Ende)",
|
||||
"comment": "Kommentar",
|
||||
"add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten",
|
||||
"add": "NAT-Regel hinzufügen",
|
||||
"edit": "NAT-Regel bearbeiten",
|
||||
"deleteConfirm": "Diese NAT-Regel wirklich löschen?",
|
||||
"namePlaceholder": "z.B. HTTP zu Web-Backend weiterleiten",
|
||||
"emptyTitle": "Noch keine NAT-Regeln.",
|
||||
@@ -156,7 +202,10 @@
|
||||
},
|
||||
"sys": {
|
||||
"title": "System-Regeln (immer aktiv)",
|
||||
"chain": "Chain", "match": "Match", "action": "Aktion", "note": "Hinweis",
|
||||
"chain": "Chain",
|
||||
"match": "Match",
|
||||
"action": "Aktion",
|
||||
"note": "Hinweis",
|
||||
"policy": "Default-Policy",
|
||||
"policyValue": "Eingang DROP — alles muss explizit erlaubt werden.",
|
||||
"order": "Auswertung",
|
||||
@@ -266,11 +315,116 @@
|
||||
"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."
|
||||
"successHint": "Du wirst zur Anmeldung weitergeleitet.",
|
||||
"back": "Zurück",
|
||||
"modeSelect": "Wie soll dieser Server eingerichtet werden?",
|
||||
"modeStandalone": "Neuinstallation",
|
||||
"modeStandaloneDesc": "Eigenständiger EdgeGuard oder erster Cluster-Knoten. Erstellt einen Admin-Account auf diesem Server.",
|
||||
"modeNode": "Vorhandenem Cluster beitreten",
|
||||
"modeNodeDesc": "Diesen Server als zweiten Cluster-Knoten hinzufügen. Admin-Zugangsdaten werden automatisch vom Primary repliziert.",
|
||||
"nodeTitle": "Cluster-Knoten einrichten",
|
||||
"nodeIntro": "Trage den FQDN dieses Knotens ein. Es wird kein Admin-Account erstellt — die Zugangsdaten kommen nach der Datenbankreplikation vom Primary.",
|
||||
"nodePreflightTitle": "Vor dem Fortfahren",
|
||||
"nodePreflightDesc": "Stelle sicher, dass EdgeGuard auf dem Primary bereits läuft und du dort einen Join-Token erstellen kannst (Cluster-Seite). Du benötigst ihn im nächsten Schritt.",
|
||||
"nodeSubmit": "Als Cluster-Knoten einrichten",
|
||||
"nodeSuccessTitle": "Knoten konfiguriert",
|
||||
"nodeJoinTitle": "Nächster Schritt: Cluster beitreten",
|
||||
"nodeJoinDesc": "Dieser Server ({{fqdn}}) ist konfiguriert. Führe jetzt den Join-Befehl aus, um ihn mit dem Primary zu verbinden.",
|
||||
"nodeStep1Title": "Join-Token auf dem Primary generieren",
|
||||
"nodeStep1Desc": "Öffne die Cluster-Seite auf deinem Primary-EdgeGuard → Knoten hinzufügen → Join-Token generieren. Token kopieren.",
|
||||
"nodeStep2Title": "Join-Befehl auf diesem Server ausführen",
|
||||
"nodeStep2Desc": "Ersetze <primary-fqdn> mit dem FQDN deines Primarys und <token> mit dem Token aus Schritt 1:",
|
||||
"nodeStep3Title": "API neu starten",
|
||||
"nodeStep3Desc": "Nach erfolgreichem Join-Befehl API neu starten, damit die replizierte Konfiguration übernommen wird:",
|
||||
"nodeLoginNote": "Der Login erfolgt mit den Admin-Zugangsdaten des Primarys. Das funktioniert sobald die Datenbankreplikation aufgebaut wurde (der Join-Befehl richtet das ein)."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "Übersicht aller laufenden EdgeGuard-Komponenten."
|
||||
"welcomeHint": "EdgeGuard-Übersicht — Health, Counts, Live-Status der wichtigsten Dienste.",
|
||||
"kpi": {
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"ifaces": "Interfaces",
|
||||
"fwRules": "FW-Regeln",
|
||||
"natRules": "NAT-Regeln",
|
||||
"wg": "WG-Verbindungen"
|
||||
},
|
||||
"wgCard": {
|
||||
"title": "WireGuard",
|
||||
"empty": "Noch kein WG-Tunnel angelegt.",
|
||||
"peersOnline": "{{online}} / {{total}} Peers verbunden"
|
||||
},
|
||||
"firewallCard": {
|
||||
"title": "Firewall",
|
||||
"zones": "Zonen",
|
||||
"activeRules": "{{rules}} aktive Regeln · {{nat}} NAT"
|
||||
},
|
||||
"sslCard": {
|
||||
"title": "SSL-Zertifikate",
|
||||
"total": "Verwaltete Zertifikate",
|
||||
"certExpired": "{{count}} Zertifikat(e) abgelaufen — sofort erneuern",
|
||||
"expiringSoon": "{{count}} läuft bald ab (< 30 Tage)",
|
||||
"allFresh": "Alle Zertifikate haben > 30 Tage Restlaufzeit."
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Knoten",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config-Drift erkannt",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
"api": "API",
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Aktuelle Alerts",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
||||
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
||||
"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)"
|
||||
},
|
||||
"activityCard": {
|
||||
"title": "Letzte Aktivität (Audit-Log)",
|
||||
"empty": "Noch keine Aktivität — Mutationen werden hier protokolliert."
|
||||
},
|
||||
"haproxyCard": {
|
||||
"title": "HAProxy-Backends (live)",
|
||||
"empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission).",
|
||||
"frontends": "Listener"
|
||||
},
|
||||
"resCard": {
|
||||
"load": "Load",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk /",
|
||||
"free": "frei",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"licenseOk": "Lizenz OK",
|
||||
"licenseTrial": "Trial",
|
||||
"licenseTrialDays": "Trial · {{days}}d"
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
@@ -680,7 +834,10 @@
|
||||
"wg": {
|
||||
"title": "WireGuard",
|
||||
"intro": "VPN-Tunnel über WireGuard. Server-Modus = wir lauschen für Peers; Client-Modus = wir verbinden zu einem festen Upstream. Privater Schlüssel liegt verschlüsselt in der DB.",
|
||||
"tabs": { "servers": "Server-Tunnel", "clients": "Client-Tunnel" },
|
||||
"tabs": {
|
||||
"servers": "Server-Tunnel",
|
||||
"clients": "Client-Tunnel"
|
||||
},
|
||||
"serverIntro": "Server-Tunnel hosten ein Peer-Roster — z.B. Mitarbeiter-Geräte oder Niederlassungen. Pro Peer bekommt der Operator eine .conf zum Download (oder QR-Code für Mobile).",
|
||||
"clientIntro": "Client-Tunnel verbinden EdgeGuard zu einem fremden WireGuard-Server (z.B. HQ-Datacenter). Allowed-IPs steuert, welcher Traffic durch den Tunnel geroutet wird.",
|
||||
"iface": {
|
||||
@@ -767,98 +924,14 @@
|
||||
"failed": "Import fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "EdgeGuard-Übersicht — Health, Counts, Live-Status der wichtigsten Dienste.",
|
||||
"kpi": {
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"ifaces": "Interfaces",
|
||||
"fwRules": "FW-Regeln",
|
||||
"natRules": "NAT-Regeln",
|
||||
"wg": "WG-Verbindungen"
|
||||
},
|
||||
"wgCard": {
|
||||
"title": "WireGuard",
|
||||
"empty": "Noch kein WG-Tunnel angelegt.",
|
||||
"peersOnline": "{{online}} / {{total}} Peers verbunden"
|
||||
},
|
||||
"firewallCard": {
|
||||
"title": "Firewall",
|
||||
"zones": "Zonen",
|
||||
"activeRules": "{{rules}} aktive Regeln · {{nat}} NAT"
|
||||
},
|
||||
"sslCard": {
|
||||
"title": "SSL-Zertifikate",
|
||||
"total": "Verwaltete Zertifikate",
|
||||
"certExpired": "{{count}} Zertifikat(e) abgelaufen — sofort erneuern",
|
||||
"expiringSoon": "{{count}} läuft bald ab (< 30 Tage)",
|
||||
"allFresh": "Alle Zertifikate haben > 30 Tage Restlaufzeit."
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Knoten",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config-Drift erkannt",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
"api": "API",
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Aktuelle Alerts",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
||||
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
||||
"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)"
|
||||
},
|
||||
"activityCard": {
|
||||
"title": "Letzte Aktivität (Audit-Log)",
|
||||
"empty": "Noch keine Aktivität — Mutationen werden hier protokolliert."
|
||||
},
|
||||
"haproxyCard": {
|
||||
"title": "HAProxy-Backends (live)",
|
||||
"empty": "Keine Backend-Stats erreichbar (HAProxy down oder admin.sock-permission).",
|
||||
"frontends": "Listener"
|
||||
},
|
||||
"resCard": {
|
||||
"load": "Load",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk /",
|
||||
"free": "frei",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"licenseOk": "Lizenz OK",
|
||||
"licenseTrial": "Trial",
|
||||
"licenseTrialDays": "Trial · {{days}}d"
|
||||
},
|
||||
"ntp": {
|
||||
"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", "peers": "Peer-Status" },
|
||||
"tabs": {
|
||||
"pools": "Quellen",
|
||||
"settings": "Settings",
|
||||
"peers": "Peer-Status"
|
||||
},
|
||||
"sourcesCard": {
|
||||
"title": "Live Peer-Status (chronyc sources)",
|
||||
"name": "Name / IP",
|
||||
@@ -934,7 +1007,11 @@
|
||||
"dns": {
|
||||
"title": "DNS (Unbound)",
|
||||
"intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.",
|
||||
"tabs": { "zones": "Zonen", "settings": "Resolver-Settings", "stats": "Resolver-Stats" },
|
||||
"tabs": {
|
||||
"zones": "Zonen",
|
||||
"settings": "Resolver-Settings",
|
||||
"stats": "Resolver-Stats"
|
||||
},
|
||||
"statsCard": {
|
||||
"title": "Resolver-Statistiken (unbound-control stats_noreset)",
|
||||
"totalQueries": "Abfragen gesamt",
|
||||
@@ -1043,22 +1120,22 @@
|
||||
},
|
||||
"actions": {
|
||||
"allow": "allow — Zugriff erlauben",
|
||||
"deny": "deny — Zugriff blockieren"
|
||||
"deny": "deny — Zugriff blockieren"
|
||||
},
|
||||
"aclTypes": {
|
||||
"src": "src — Quell-IP/CIDR",
|
||||
"dst": "dst — Ziel-IP/CIDR",
|
||||
"dstdomain": "dstdomain — Ziel-Domain (exact)",
|
||||
"srcdomain": "srcdomain — Quell-Domain (rDNS)",
|
||||
"port": "port — Ziel-Port",
|
||||
"proto": "proto — http/https/ftp/...",
|
||||
"method": "method — GET/POST/CONNECT/...",
|
||||
"time": "time — Wochentag/Zeit",
|
||||
"url_regex": "url_regex — kompletter URL-Match",
|
||||
"src": "src — Quell-IP/CIDR",
|
||||
"dst": "dst — Ziel-IP/CIDR",
|
||||
"dstdomain": "dstdomain — Ziel-Domain (exact)",
|
||||
"srcdomain": "srcdomain — Quell-Domain (rDNS)",
|
||||
"port": "port — Ziel-Port",
|
||||
"proto": "proto — http/https/ftp/...",
|
||||
"method": "method — GET/POST/CONNECT/...",
|
||||
"time": "time — Wochentag/Zeit",
|
||||
"url_regex": "url_regex — kompletter URL-Match",
|
||||
"urlpath_regex": "urlpath_regex — Path-Teil",
|
||||
"dstdom_regex": "dstdom_regex — Domain-Regex",
|
||||
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
|
||||
"browser": "browser — User-Agent-Regex"
|
||||
"dstdom_regex": "dstdom_regex — Domain-Regex",
|
||||
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
|
||||
"browser": "browser — User-Agent-Regex"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1173,7 +1250,10 @@
|
||||
"intro": "Notification-Channels für kritische Events. Webhook (Slack/Discord/Teams/Generic-HTTP) oder Email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.",
|
||||
"scopeTitle": "Was triggert Alarme?",
|
||||
"scopeDesc": "cert.expiring — TLS-Zertifikat <14 Tage Restzeit (dedupe 12h). cert.renew_failed — ACME-Renewer hat Fails. backup.failed — Scheduled Backup konnte nicht erstellt werden. license.invalid — License-Server liefert valid=false. backend.down — alle Server eines Backend-Pools sind DOWN (2-Min-Check, dedupe 12h). disk.full — Root-Filesystem ≥80% Warnung, ≥90% Critical (stündlich, dedupe 12h). mem.high — RAM-Auslastung ≥85% Warnung, ≥95% Critical (5-Min-Check, dedupe 12h). conntrack.high — Conntrack-Tabelle ≥80% Warnung, ≥90% Critical; bei 100% werden alle neuen Verbindungen lautlos verworfen (2-Min-Check, dedupe 12h). ntp.unsync — chrony hat keine synchronisierte Zeitquelle; Drift führt zu TLS- und JWT-Fehlern (10-Min-Check, dedupe 12h). wg.tunnel.down — WireGuard-Client-Tunnel hat seit >5 Min keinen Handshake; Remote-Traffic wird lautlos verworfen (5-Min-Check, dedupe 12h).",
|
||||
"tabs": { "channels": "Channels", "events": "History" },
|
||||
"tabs": {
|
||||
"channels": "Channels",
|
||||
"events": "History"
|
||||
},
|
||||
"add": "Channel hinzufügen",
|
||||
"addTitle": "Notification-Channel anlegen",
|
||||
"editTitle": "Channel bearbeiten",
|
||||
@@ -1264,11 +1344,21 @@
|
||||
"run": "Ausführen",
|
||||
"runFromBoxTitle": "Tools laufen auf der EdgeGuard-Box",
|
||||
"runFromBoxDesc": "Diese Aufrufe nutzen die Outbound-Connectivity der EdgeGuard — nicht deinen lokalen Rechner. Wenn ping/curl auf der Box scheitert, ist das Box-Network-Layer das Problem (Firewall-Rules, Default-Route, DNS).",
|
||||
"ping": { "intro": "ICMP echo-request × 4, 2s Timeout pro Paket. Misst Loss + RTT." },
|
||||
"trace": { "intro": "Hop-by-Hop-Pfad mit UDP-Probes (max 20 Hops, kein reverse-DNS)." },
|
||||
"dig": { "intro": "DNS-Lookup über unbound (Box-Resolver). Zeigt Antwort + AUTHORITY/ADDITIONAL Section." },
|
||||
"curl": { "intro": "HTTPS-Probe mit -IsSv: TLS-Handshake-Details + Header. Folgt KEINE Redirects." },
|
||||
"tcp": { "intro": "Pure TCP-Connect ohne I/O. Ideal um zu prüfen ob ein Backend-Port erreichbar ist." },
|
||||
"ping": {
|
||||
"intro": "ICMP echo-request × 4, 2s Timeout pro Paket. Misst Loss + RTT."
|
||||
},
|
||||
"trace": {
|
||||
"intro": "Hop-by-Hop-Pfad mit UDP-Probes (max 20 Hops, kein reverse-DNS)."
|
||||
},
|
||||
"dig": {
|
||||
"intro": "DNS-Lookup über unbound (Box-Resolver). Zeigt Antwort + AUTHORITY/ADDITIONAL Section."
|
||||
},
|
||||
"curl": {
|
||||
"intro": "HTTPS-Probe mit -IsSv: TLS-Handshake-Details + Header. Folgt KEINE Redirects."
|
||||
},
|
||||
"tcp": {
|
||||
"intro": "Pure TCP-Connect ohne I/O. Ideal um zu prüfen ob ein Backend-Port erreichbar ist."
|
||||
},
|
||||
"securityTitle": "Sicherheits-Hinweis",
|
||||
"securityDesc": "Endpoints sind hinter Admin-Auth. Targets werden gegen eine strikte Allow-Liste validiert (keine Shell-Metazeichen). curl ist auf http(s) beschränkt — kein file:// / smb:// / data://.",
|
||||
"noOutput": "(kein Output)"
|
||||
@@ -1278,7 +1368,10 @@
|
||||
"intro": "Sicherungen der PostgreSQL-Datenbank + /var/lib/edgeguard (Setup, License, JWT, ACME-Account). Täglicher Auto-Job + manueller Trigger.",
|
||||
"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" },
|
||||
"tabs": {
|
||||
"history": "Sicherungen",
|
||||
"remotes": "Off-Site-Ziele"
|
||||
},
|
||||
"statLastSuccess": "Letzte erfolgreiche Sicherung",
|
||||
"statTotal": "Erfolgreiche Sicherungen",
|
||||
"statSize": "Gesamtgröße",
|
||||
|
||||
@@ -63,52 +63,87 @@
|
||||
"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",
|
||||
"name": "Name",
|
||||
"kind": "Kind",
|
||||
"value": "Value",
|
||||
"description": "Description",
|
||||
"add": "Add address object",
|
||||
"edit": "Edit address object",
|
||||
"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",
|
||||
"name": "Name",
|
||||
"members": "Members",
|
||||
"description": "Description",
|
||||
"add": "Add address group",
|
||||
"edit": "Edit address group",
|
||||
"selectMembers": "Select address objects",
|
||||
"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",
|
||||
"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}}?",
|
||||
"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",
|
||||
"name": "Name",
|
||||
"members": "Members",
|
||||
"description": "Description",
|
||||
"add": "Add service group",
|
||||
"edit": "Edit service group",
|
||||
"selectMembers": "Select services",
|
||||
"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",
|
||||
"action": "Action", "src": "Source", "dst": "Destination", "service": "Service",
|
||||
"srcZone": "Source zone", "dstZone": "Dest. zone",
|
||||
"srcKind": "Source kind", "dstKind": "Dest. kind",
|
||||
"object": "Address object", "group": "Address group",
|
||||
"serviceKind": "Service kind", "serviceGroup": "Service group",
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"enabled": "Enabled",
|
||||
"log": "Log",
|
||||
"action": "Action",
|
||||
"src": "Source",
|
||||
"dst": "Destination",
|
||||
"service": "Service",
|
||||
"srcZone": "Source zone",
|
||||
"dstZone": "Dest. zone",
|
||||
"srcKind": "Source kind",
|
||||
"dstKind": "Dest. kind",
|
||||
"object": "Address object",
|
||||
"group": "Address group",
|
||||
"serviceKind": "Service kind",
|
||||
"serviceGroup": "Service group",
|
||||
"comment": "Comment",
|
||||
"hits": "Hits",
|
||||
"add": "Add rule", "edit": "Edit rule",
|
||||
"add": "Add rule",
|
||||
"edit": "Edit rule",
|
||||
"deleteConfirm": "Really delete this rule?",
|
||||
"namePlaceholder": "e.g. Allow SSH from office",
|
||||
"cidr": "CIDR",
|
||||
"actions": { "accept": "ACCEPT", "drop": "DROP", "reject": "REJECT" },
|
||||
"kinds": { "any": "Any", "object": "Object", "group": "Group", "cidr": "CIDR" },
|
||||
"actions": {
|
||||
"accept": "ACCEPT",
|
||||
"drop": "DROP",
|
||||
"reject": "REJECT"
|
||||
},
|
||||
"kinds": {
|
||||
"any": "Any",
|
||||
"object": "Object",
|
||||
"group": "Group",
|
||||
"cidr": "CIDR"
|
||||
},
|
||||
"duplicate": "Duplicate rule",
|
||||
"duplicated": "Rule duplicated (disabled, priority +1).",
|
||||
"cidrInvalid": "Enter a valid CIDR (e.g. 10.0.0.0/24)",
|
||||
@@ -139,16 +174,27 @@
|
||||
"noResultsHint": "Clear the filter to see all rules."
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled",
|
||||
"match": "Match", "target": "Target",
|
||||
"inZone": "Ingress zone", "outZone": "Egress zone", "proto": "Protocol",
|
||||
"matchSrcCidr": "Source CIDR (match)", "matchDstCidr": "Dest. CIDR (match)",
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"kind": "Kind",
|
||||
"enabled": "Enabled",
|
||||
"match": "Match",
|
||||
"target": "Target",
|
||||
"inZone": "Ingress zone",
|
||||
"outZone": "Egress zone",
|
||||
"proto": "Protocol",
|
||||
"matchSrcCidr": "Source CIDR (match)",
|
||||
"matchDstCidr": "Dest. CIDR (match)",
|
||||
"matchDstCidrHint": "empty = any dest IP (e.g. box's public IP)",
|
||||
"dportStart": "Port (start)", "dportEnd": "Port (end)",
|
||||
"dportStart": "Port (start)",
|
||||
"dportEnd": "Port (end)",
|
||||
"portRangeError": "End port must be ≥ start port",
|
||||
"targetAddr": "Target address", "targetPortStart": "Target port (start)", "targetPortEnd": "Target port (end)",
|
||||
"targetAddr": "Target address",
|
||||
"targetPortStart": "Target port (start)",
|
||||
"targetPortEnd": "Target port (end)",
|
||||
"comment": "Comment",
|
||||
"add": "Add NAT rule", "edit": "Edit NAT rule",
|
||||
"add": "Add NAT rule",
|
||||
"edit": "Edit NAT rule",
|
||||
"deleteConfirm": "Really delete this NAT rule?",
|
||||
"namePlaceholder": "e.g. Forward HTTP to web backend",
|
||||
"emptyTitle": "No NAT rules yet.",
|
||||
@@ -156,7 +202,10 @@
|
||||
},
|
||||
"sys": {
|
||||
"title": "System rules (always active)",
|
||||
"chain": "Chain", "match": "Match", "action": "Action", "note": "Note",
|
||||
"chain": "Chain",
|
||||
"match": "Match",
|
||||
"action": "Action",
|
||||
"note": "Note",
|
||||
"policy": "Default policy",
|
||||
"policyValue": "Input DROP — everything must be explicitly allowed.",
|
||||
"order": "Evaluation",
|
||||
@@ -266,11 +315,116 @@
|
||||
"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."
|
||||
"successHint": "Redirecting you to sign-in.",
|
||||
"back": "Back",
|
||||
"modeSelect": "How would you like to set up this box?",
|
||||
"modeStandalone": "Fresh installation",
|
||||
"modeStandaloneDesc": "Set up as a standalone EdgeGuard or the first node of a new cluster. Creates an admin account on this box.",
|
||||
"modeNode": "Join existing cluster",
|
||||
"modeNodeDesc": "Add this box as a second cluster node. Admin credentials are replicated from the primary automatically — no separate account needed.",
|
||||
"nodeTitle": "Cluster Node Setup",
|
||||
"nodeIntro": "Enter the FQDN of this node. No admin account is created here — login credentials come from the primary via database replication after you run the join command.",
|
||||
"nodePreflightTitle": "Before you continue",
|
||||
"nodePreflightDesc": "Make sure EdgeGuard is already running on your primary node and you can generate a join token there (Cluster page). You will need it in the next step.",
|
||||
"nodeSubmit": "Configure as cluster node",
|
||||
"nodeSuccessTitle": "Node configured",
|
||||
"nodeJoinTitle": "Next: join the cluster",
|
||||
"nodeJoinDesc": "This box ({{fqdn}}) is configured. Now run the join command below to connect it to the primary.",
|
||||
"nodeStep1Title": "Generate a join token on the primary",
|
||||
"nodeStep1Desc": "Open the Cluster page on your primary EdgeGuard → Add node → Generate join token. Copy the token.",
|
||||
"nodeStep2Title": "Run the join command on this box",
|
||||
"nodeStep2Desc": "Replace <primary-fqdn> with your primary's FQDN and <token> with the token from step 1:",
|
||||
"nodeStep3Title": "Restart the API",
|
||||
"nodeStep3Desc": "After the join command completes, restart the API to pick up the replicated configuration:",
|
||||
"nodeLoginNote": "Login uses the admin credentials from the primary. This will work once database replication is established (the join command sets this up)."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "Overview of all running EdgeGuard components."
|
||||
"welcomeHint": "EdgeGuard overview — health, counts, live status of the major services.",
|
||||
"kpi": {
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"ifaces": "Interfaces",
|
||||
"fwRules": "FW rules",
|
||||
"natRules": "NAT rules",
|
||||
"wg": "WG connections"
|
||||
},
|
||||
"wgCard": {
|
||||
"title": "WireGuard",
|
||||
"empty": "No WG tunnel configured yet.",
|
||||
"peersOnline": "{{online}} / {{total}} peers online"
|
||||
},
|
||||
"firewallCard": {
|
||||
"title": "Firewall",
|
||||
"zones": "Zones",
|
||||
"activeRules": "{{rules}} active rules · {{nat}} NAT"
|
||||
},
|
||||
"sslCard": {
|
||||
"title": "SSL certificates",
|
||||
"total": "Managed certificates",
|
||||
"certExpired": "{{count}} certificate(s) expired — renew immediately",
|
||||
"expiringSoon": "{{count}} expiring soon (< 30 days)",
|
||||
"allFresh": "All certs have > 30 days remaining."
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Nodes",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config drift detected",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} domains have a primary backend"
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
"api": "API",
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Recent alerts",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
||||
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
||||
"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)"
|
||||
},
|
||||
"activityCard": {
|
||||
"title": "Recent activity (audit log)",
|
||||
"empty": "No activity yet — mutations are logged here."
|
||||
},
|
||||
"haproxyCard": {
|
||||
"title": "HAProxy backends (live)",
|
||||
"empty": "No backend stats reachable (HAProxy down or admin.sock permission).",
|
||||
"frontends": "Listeners"
|
||||
},
|
||||
"resCard": {
|
||||
"load": "Load",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk /",
|
||||
"free": "free",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"licenseOk": "License OK",
|
||||
"licenseTrial": "Trial",
|
||||
"licenseTrialDays": "Trial · {{days}}d"
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
@@ -680,7 +834,10 @@
|
||||
"wg": {
|
||||
"title": "WireGuard",
|
||||
"intro": "WireGuard VPN tunnels. Server mode = we listen for peers; client mode = we dial out to a fixed upstream. Private keys are encrypted at rest.",
|
||||
"tabs": { "servers": "Server tunnels", "clients": "Client tunnels" },
|
||||
"tabs": {
|
||||
"servers": "Server tunnels",
|
||||
"clients": "Client tunnels"
|
||||
},
|
||||
"serverIntro": "Server tunnels host a peer roster — typically employee devices or branch sites. Each peer can be downloaded as a wg-quick.conf or scanned as a QR code.",
|
||||
"clientIntro": "Client tunnels connect EdgeGuard to a remote WireGuard server (e.g. HQ datacenter). Allowed IPs control which traffic is routed through the tunnel.",
|
||||
"iface": {
|
||||
@@ -767,98 +924,14 @@
|
||||
"failed": "Import failed"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "EdgeGuard overview — health, counts, live status of the major services.",
|
||||
"kpi": {
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"ifaces": "Interfaces",
|
||||
"fwRules": "FW rules",
|
||||
"natRules": "NAT rules",
|
||||
"wg": "WG connections"
|
||||
},
|
||||
"wgCard": {
|
||||
"title": "WireGuard",
|
||||
"empty": "No WG tunnel configured yet.",
|
||||
"peersOnline": "{{online}} / {{total}} peers online"
|
||||
},
|
||||
"firewallCard": {
|
||||
"title": "Firewall",
|
||||
"zones": "Zones",
|
||||
"activeRules": "{{rules}} active rules · {{nat}} NAT"
|
||||
},
|
||||
"sslCard": {
|
||||
"title": "SSL certificates",
|
||||
"total": "Managed certificates",
|
||||
"certExpired": "{{count}} certificate(s) expired — renew immediately",
|
||||
"expiringSoon": "{{count}} expiring soon (< 30 days)",
|
||||
"allFresh": "All certs have > 30 days remaining."
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Nodes",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config drift detected",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} domains have a primary backend"
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
"api": "API",
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Recent alerts",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
||||
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
||||
"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)"
|
||||
},
|
||||
"activityCard": {
|
||||
"title": "Recent activity (audit log)",
|
||||
"empty": "No activity yet — mutations are logged here."
|
||||
},
|
||||
"haproxyCard": {
|
||||
"title": "HAProxy backends (live)",
|
||||
"empty": "No backend stats reachable (HAProxy down or admin.sock permission).",
|
||||
"frontends": "Listeners"
|
||||
},
|
||||
"resCard": {
|
||||
"load": "Load",
|
||||
"memory": "Memory",
|
||||
"disk": "Disk /",
|
||||
"free": "free",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime"
|
||||
},
|
||||
"licenseOk": "License OK",
|
||||
"licenseTrial": "Trial",
|
||||
"licenseTrialDays": "Trial · {{days}}d"
|
||||
},
|
||||
"ntp": {
|
||||
"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", "peers": "Peer status" },
|
||||
"tabs": {
|
||||
"pools": "Sources",
|
||||
"settings": "Settings",
|
||||
"peers": "Peer status"
|
||||
},
|
||||
"sourcesCard": {
|
||||
"title": "Live peer status (chronyc sources)",
|
||||
"name": "Name / IP",
|
||||
@@ -934,7 +1007,11 @@
|
||||
"dns": {
|
||||
"title": "DNS (Unbound)",
|
||||
"intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.",
|
||||
"tabs": { "zones": "Zones", "settings": "Resolver settings", "stats": "Resolver stats" },
|
||||
"tabs": {
|
||||
"zones": "Zones",
|
||||
"settings": "Resolver settings",
|
||||
"stats": "Resolver stats"
|
||||
},
|
||||
"statsCard": {
|
||||
"title": "Resolver statistics (unbound-control stats_noreset)",
|
||||
"totalQueries": "Total queries",
|
||||
@@ -1043,22 +1120,22 @@
|
||||
},
|
||||
"actions": {
|
||||
"allow": "allow — permit access",
|
||||
"deny": "deny — block access"
|
||||
"deny": "deny — block access"
|
||||
},
|
||||
"aclTypes": {
|
||||
"src": "src — source IP/CIDR",
|
||||
"dst": "dst — destination IP/CIDR",
|
||||
"dstdomain": "dstdomain — destination domain (exact)",
|
||||
"srcdomain": "srcdomain — source domain (rDNS)",
|
||||
"port": "port — destination port",
|
||||
"proto": "proto — http/https/ftp/...",
|
||||
"method": "method — GET/POST/CONNECT/...",
|
||||
"time": "time — weekday/time range",
|
||||
"url_regex": "url_regex — full URL regex",
|
||||
"src": "src — source IP/CIDR",
|
||||
"dst": "dst — destination IP/CIDR",
|
||||
"dstdomain": "dstdomain — destination domain (exact)",
|
||||
"srcdomain": "srcdomain — source domain (rDNS)",
|
||||
"port": "port — destination port",
|
||||
"proto": "proto — http/https/ftp/...",
|
||||
"method": "method — GET/POST/CONNECT/...",
|
||||
"time": "time — weekday/time range",
|
||||
"url_regex": "url_regex — full URL regex",
|
||||
"urlpath_regex": "urlpath_regex — URL path regex",
|
||||
"dstdom_regex": "dstdom_regex — destination domain regex",
|
||||
"srcdom_regex": "srcdom_regex — source domain regex",
|
||||
"browser": "browser — User-Agent regex"
|
||||
"dstdom_regex": "dstdom_regex — destination domain regex",
|
||||
"srcdom_regex": "srcdom_regex — source domain regex",
|
||||
"browser": "browser — User-Agent regex"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
@@ -1173,7 +1250,10 @@
|
||||
"intro": "Notification channels for critical events. Webhook (Slack/Discord/Teams/generic-HTTP) or email (SMTP). Triggers: cert.expiring (<14 d), cert.renew_failed, backup.failed, license.invalid.",
|
||||
"scopeTitle": "What triggers alerts?",
|
||||
"scopeDesc": "cert.expiring — TLS cert <14 days remaining (12 h dedupe). cert.renew_failed — ACME renewer cycle had failures. backup.failed — scheduled backup couldn't run. license.invalid — License server returns valid=false. backend.down — all servers in a backend pool are DOWN (2 min check, 12 h dedupe). disk.full — root filesystem ≥80% warning, ≥90% critical (hourly check, 12 h dedupe). mem.high — RAM usage ≥85% warning, ≥95% critical (5 min check, 12 h dedupe). conntrack.high — conntrack table ≥80% warning, ≥90% critical; at 100% all new connections are silently dropped (2 min check, 12 h dedupe). ntp.unsync — chrony has no synchronized time source; clock drift causes TLS and JWT failures (10 min check, 12 h dedupe). wg.tunnel.down — WireGuard client tunnel has no handshake for >5 min; remote traffic is silently dropped (5 min check, 12 h dedupe).",
|
||||
"tabs": { "channels": "Channels", "events": "History" },
|
||||
"tabs": {
|
||||
"channels": "Channels",
|
||||
"events": "History"
|
||||
},
|
||||
"add": "Add channel",
|
||||
"addTitle": "Add notification channel",
|
||||
"editTitle": "Edit channel",
|
||||
@@ -1264,11 +1344,21 @@
|
||||
"run": "Run",
|
||||
"runFromBoxTitle": "Tools run on the EdgeGuard box",
|
||||
"runFromBoxDesc": "These calls use the EdgeGuard's outbound connectivity — not your laptop's. If ping/curl fails here, the box's network layer is the issue (firewall rules, default route, DNS).",
|
||||
"ping": { "intro": "ICMP echo-request × 4, 2 s timeout per packet. Measures loss + RTT." },
|
||||
"trace": { "intro": "Hop-by-hop path with UDP probes (max 20 hops, no reverse-DNS)." },
|
||||
"dig": { "intro": "DNS lookup via unbound (box resolver). Shows answer + AUTHORITY/ADDITIONAL section." },
|
||||
"curl": { "intro": "HTTPS probe with -IsSv: TLS handshake details + headers. Does NOT follow redirects." },
|
||||
"tcp": { "intro": "Pure TCP connect (no I/O). Ideal to verify a backend port is reachable." },
|
||||
"ping": {
|
||||
"intro": "ICMP echo-request × 4, 2 s timeout per packet. Measures loss + RTT."
|
||||
},
|
||||
"trace": {
|
||||
"intro": "Hop-by-hop path with UDP probes (max 20 hops, no reverse-DNS)."
|
||||
},
|
||||
"dig": {
|
||||
"intro": "DNS lookup via unbound (box resolver). Shows answer + AUTHORITY/ADDITIONAL section."
|
||||
},
|
||||
"curl": {
|
||||
"intro": "HTTPS probe with -IsSv: TLS handshake details + headers. Does NOT follow redirects."
|
||||
},
|
||||
"tcp": {
|
||||
"intro": "Pure TCP connect (no I/O). Ideal to verify a backend port is reachable."
|
||||
},
|
||||
"securityTitle": "Security note",
|
||||
"securityDesc": "Endpoints are behind admin auth. Targets are validated against a strict allow-list (no shell metachars). curl is restricted to http(s) — no file:// / smb:// / data://.",
|
||||
"noOutput": "(no output)"
|
||||
@@ -1278,7 +1368,10 @@
|
||||
"intro": "Snapshots of the PostgreSQL database + /var/lib/edgeguard (setup, license, JWT, ACME account). Daily auto job + manual trigger.",
|
||||
"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" },
|
||||
"tabs": {
|
||||
"history": "Backups",
|
||||
"remotes": "Off-site targets"
|
||||
},
|
||||
"statLastSuccess": "Last successful backup",
|
||||
"statTotal": "Successful backups",
|
||||
"statSize": "Total size",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, Card, Form, Input, Space, Tag, Typography, message } from 'antd'
|
||||
import { ArrowLeftOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -17,20 +19,36 @@ 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).
|
||||
interface NodeValues {
|
||||
fqdn: string
|
||||
acme_email: string
|
||||
}
|
||||
|
||||
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
type Mode = 'standalone' | 'node'
|
||||
|
||||
function CopyCode({ value }: { value: string }) {
|
||||
return (
|
||||
<Input.TextArea
|
||||
value={value}
|
||||
readOnly
|
||||
autoSize
|
||||
style={{ fontFamily: 'monospace', fontSize: 12, background: '#f6f8fa', cursor: 'text' }}
|
||||
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SetupPage({ onComplete: _onComplete }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [mode, setMode] = useState<Mode | null>(null)
|
||||
const [nodeDone, setNodeDone] = useState(false)
|
||||
const [nodeFqdn, setNodeFqdn] = useState('')
|
||||
|
||||
const onFinish = async (vals: SetupValues) => {
|
||||
try {
|
||||
// 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(),
|
||||
@@ -39,7 +57,6 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
||||
}
|
||||
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 })
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string }
|
||||
@@ -47,81 +64,240 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const onFinishNode = async (vals: NodeValues) => {
|
||||
try {
|
||||
const normalised: NodeValues = {
|
||||
fqdn: vals.fqdn.trim().toLowerCase(),
|
||||
acme_email: vals.acme_email.trim().toLowerCase(),
|
||||
}
|
||||
await apiClient.post('/setup/complete-node', normalised)
|
||||
setNodeFqdn(normalised.fqdn)
|
||||
setNodeDone(true)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string }
|
||||
message.error(err.message ?? t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const joinCmd = `sudo edgeguard-ctl cluster-join <primary-fqdn> --token <token>`
|
||||
const restartCmd = `sudo systemctl restart edgeguard-api`
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Card style={{ width: 580 }}>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('setup.preflightTitle')}
|
||||
description={t('setup.preflightDesc')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{/* ── Mode selection ── */}
|
||||
{mode === null && (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>EdgeGuard Setup</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||
{t('setup.modeSelect')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<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 placeholder="admin@example.com" />
|
||||
</Form.Item>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ cursor: 'pointer', borderColor: '#1677ff' }}
|
||||
onClick={() => setMode('standalone')}
|
||||
>
|
||||
<Space align="start">
|
||||
<DesktopOutlined style={{ fontSize: 28, color: '#1677ff', marginTop: 2 }} />
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>{t('setup.modeStandalone')}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary">{t('setup.modeStandaloneDesc')}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.adminPassword')}
|
||||
name="admin_password"
|
||||
extra={t('setup.passwordRule')}
|
||||
rules={[{ required: true, min: 12, message: t('setup.passwordRule') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Card
|
||||
hoverable
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setMode('node')}
|
||||
>
|
||||
<Space align="start">
|
||||
<ClusterOutlined style={{ fontSize: 28, color: '#52c41a', marginTop: 2 }} />
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>{t('setup.modeNode')}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary">{t('setup.modeNodeDesc')}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.fqdn')}
|
||||
name="fqdn"
|
||||
extra={t('setup.fqdnHint')}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
pattern: FQDN_RE,
|
||||
message: t('setup.fqdnInvalid'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="eg.example.com" />
|
||||
</Form.Item>
|
||||
{/* ── Standalone form ── */}
|
||||
{mode === 'standalone' && (
|
||||
<>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
|
||||
{t('setup.back')}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.acmeEmail')}
|
||||
name="acme_email"
|
||||
extra={t('setup.acmeEmailHint')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input placeholder="ops@example.com" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('setup.preflightTitle')}
|
||||
description={t('setup.preflightDesc')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.licenseKey')}
|
||||
name="license_key"
|
||||
extra={t('setup.licenseKeyHint')}
|
||||
>
|
||||
<Input placeholder="EG-XXXX-XXXX-XXXX" />
|
||||
</Form.Item>
|
||||
<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 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') }]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.fqdn')}
|
||||
name="fqdn"
|
||||
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 placeholder="ops@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.licenseKey')}
|
||||
name="license_key"
|
||||
extra={t('setup.licenseKeyHint')}
|
||||
>
|
||||
<Input placeholder="EG-XXXX-XXXX-XXXX" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('setup.submit')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Cluster node form ── */}
|
||||
{mode === 'node' && !nodeDone && (
|
||||
<>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
|
||||
{t('setup.back')}
|
||||
</Button>
|
||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.nodeTitle')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{t('setup.nodeIntro')}
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('setup.nodePreflightTitle')}
|
||||
description={t('setup.nodePreflightDesc')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form layout="vertical" onFinish={onFinishNode}>
|
||||
<Form.Item
|
||||
label={t('setup.fqdn')}
|
||||
name="fqdn"
|
||||
extra={t('setup.fqdnHint')}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: FQDN_RE, message: t('setup.fqdnInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input autoFocus placeholder="eg2.example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.acmeEmail')}
|
||||
name="acme_email"
|
||||
extra={t('setup.acmeEmailHint')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input placeholder="ops@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('setup.nodeSubmit')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Cluster node success ── */}
|
||||
{mode === 'node' && nodeDone && (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Tag color="success" style={{ marginBottom: 8 }}>{t('setup.nodeSuccessTitle')}</Tag>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>{t('setup.nodeJoinTitle')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('setup.nodeJoinDesc', { fqdn: nodeFqdn })}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>1. {t('setup.nodeStep1Title')}</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0' }}>
|
||||
{t('setup.nodeStep1Desc')}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>2. {t('setup.nodeStep2Title')}</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 8px' }}>
|
||||
{t('setup.nodeStep2Desc')}
|
||||
</Typography.Paragraph>
|
||||
<CopyCode value={joinCmd} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>3. {t('setup.nodeStep3Title')}</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 8px' }}>
|
||||
{t('setup.nodeStep3Desc')}
|
||||
</Typography.Paragraph>
|
||||
<CopyCode value={restartCmd} />
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('setup.nodeLoginNote')}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('setup.submit')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user