feat(setup): Cluster-Join komplett in GUI — kein CLI mehr nötig

POST /setup/join-cluster macht alles was bisher edgeguard-ctl cluster-join
tat: CSR generieren, Certs vom Primary holen, schreiben, auto-registrieren,
Setup als Cluster-Node markieren.

Setup-Wizard Node-Modus fragt jetzt direkt Primary-FQDN + Join-Token ab.
Nach Submit: Erfolgsmeldung + einziger verbleibender Schritt (systemctl restart).

Neue interne Bibliothek: internal/services/clusterjoin — wird von Handler
und CLI (edgeguard-ctl cluster-join) gleichermaßen genutzt, keine Duplizierung.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 17:08:49 +02:00
parent 7e5a7a83c0
commit afe2c951b9
10 changed files with 437 additions and 321 deletions

View File

@@ -327,7 +327,7 @@
"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",
"nodeSuccessTitle": "Erfolgreich beigetreten!",
"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",
@@ -336,7 +336,15 @@
"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)."
"nodeLoginNote": "Der Login erfolgt mit den Admin-Zugangsdaten des Primarys. Das funktioniert sobald die Datenbankreplikation aufgebaut wurde (der Join-Befehl richtet das ein).",
"primaryFqdn": "Primary-FQDN",
"primaryFqdnHint": "FQDN des bestehenden Primary-EdgeGuard-Knotens (z.B. eg1.example.com).",
"joinToken": "Join-Token",
"joinTokenHint": "Auf dem Primary generieren: Cluster-Seite → Knoten hinzufügen → Join-Token generieren.",
"joinInsecure": "TLS-Prüfung überspringen (falls der Primary ein self-signed Zertifikat hat)",
"nodeSuccessDesc": "Cluster-Zertifikate wurden geschrieben. Noch ein letzter Schritt:",
"nodeRestartTitle": "Neustart erforderlich",
"nodeRestartDesc": "Führe folgenden Befehl auf diesem Server aus, um die neuen Cluster-Zertifikate zu laden:"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -327,7 +327,7 @@
"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",
"nodeSuccessTitle": "Successfully joined!",
"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",
@@ -336,7 +336,15 @@
"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)."
"nodeLoginNote": "Login uses the admin credentials from the primary. This will work once database replication is established (the join command sets this up).",
"primaryFqdn": "Primary FQDN",
"primaryFqdnHint": "FQDN of the existing primary EdgeGuard node (e.g. eg1.example.com).",
"joinToken": "Join token",
"joinTokenHint": "Generated on the primary: Cluster page → Add node → Generate join token.",
"joinInsecure": "Skip TLS verification (use if the primary has a self-signed certificate)",
"nodeSuccessDesc": "Cluster certs have been written. One last step:",
"nodeRestartTitle": "Restart required",
"nodeRestartDesc": "Run the following command on this box to load the new cluster certificates:"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -1,6 +1,6 @@
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 { Alert, Button, Card, Checkbox, Form, Input, Space, Typography, message } from 'antd'
import { ArrowLeftOutlined, CheckCircleOutlined, ClusterOutlined, DesktopOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -19,9 +19,12 @@ interface SetupValues {
license_key?: string
}
interface NodeValues {
interface JoinValues {
fqdn: string
acme_email: string
primary_fqdn: string
token: string
insecure?: boolean
}
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
@@ -44,10 +47,11 @@ 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 [joinDone, setJoinDone] = useState(false)
const [loading, setLoading] = useState(false)
const onFinish = async (vals: SetupValues) => {
setLoading(true)
try {
const normalised: SetupValues = {
...vals,
@@ -61,25 +65,30 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
} catch (e: unknown) {
const err = e as { message?: string }
message.error(err.message ?? t('common.error'))
} finally {
setLoading(false)
}
}
const onFinishNode = async (vals: NodeValues) => {
const onJoin = async (vals: JoinValues) => {
setLoading(true)
try {
const normalised: NodeValues = {
await apiClient.post('/setup/join-cluster', {
fqdn: vals.fqdn.trim().toLowerCase(),
acme_email: vals.acme_email.trim().toLowerCase(),
}
await apiClient.post('/setup/complete-node', normalised)
setNodeFqdn(normalised.fqdn)
setNodeDone(true)
primary_fqdn: vals.primary_fqdn.trim().toLowerCase(),
token: vals.token.trim(),
insecure: vals.insecure ?? false,
})
setJoinDone(true)
} catch (e: unknown) {
const err = e as { message?: string }
message.error(err.message ?? t('common.error'))
} finally {
setLoading(false)
}
}
const joinCmd = `sudo edgeguard-ctl cluster-join <primary-fqdn> --token <token>`
const restartCmd = `sudo systemctl restart edgeguard-api`
return (
@@ -129,12 +138,12 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
{/* ── 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>
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
{t('setup.back')}
</Button>
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 12 }}>
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{t('setup.intro')}
</Typography.Paragraph>
</Space>
@@ -196,7 +205,7 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" block>
<Button type="primary" htmlType="submit" block loading={loading}>
{t('setup.submit')}
</Button>
</Form.Item>
@@ -204,28 +213,28 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</>
)}
{/* ── Cluster node form ── */}
{mode === 'node' && !nodeDone && (
{/* ── Cluster node join form ── */}
{mode === 'node' && !joinDone && (
<>
<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>
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 8 }} onClick={() => setMode(null)}>
{t('setup.back')}
</Button>
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 12 }}>
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.nodeTitle')}</Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{t('setup.nodeIntro')}
</Typography.Paragraph>
</Space>
<Alert
type="warning"
type="info"
showIcon
message={t('setup.nodePreflightTitle')}
description={t('setup.nodePreflightDesc')}
style={{ marginBottom: 16 }}
/>
<Form layout="vertical" onFinish={onFinishNode}>
<Form layout="vertical" onFinish={onJoin}>
<Form.Item
label={t('setup.fqdn')}
name="fqdn"
@@ -247,8 +256,37 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
<Input placeholder="ops@example.com" />
</Form.Item>
<Form.Item
label={t('setup.primaryFqdn')}
name="primary_fqdn"
extra={t('setup.primaryFqdnHint')}
rules={[
{ required: true },
{ pattern: FQDN_RE, message: t('setup.fqdnInvalid') },
]}
>
<Input placeholder="eg1.example.com" />
</Form.Item>
<Form.Item
label={t('setup.joinToken')}
name="token"
extra={t('setup.joinTokenHint')}
rules={[{ required: true }]}
>
<Input.TextArea
rows={3}
placeholder="eg-join-v1.eyJ…"
style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
</Form.Item>
<Form.Item name="insecure" valuePropName="checked">
<Checkbox>{t('setup.joinInsecure')}</Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" block>
<Button type="primary" htmlType="submit" block loading={loading}>
{t('setup.nodeSubmit')}
</Button>
</Form.Item>
@@ -256,39 +294,28 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
</>
)}
{/* ── 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>
{/* ── Join success ── */}
{mode === 'node' && joinDone && (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Space>
<CheckCircleOutlined style={{ fontSize: 32, color: '#52c41a' }} />
<div>
<Typography.Title level={4} style={{ margin: 0 }}>{t('setup.nodeSuccessTitle')}</Typography.Title>
<Typography.Text type="secondary">{t('setup.nodeSuccessDesc')}</Typography.Text>
</div>
</Space>
<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="warning"
showIcon
message={t('setup.nodeRestartTitle')}
description={
<Space direction="vertical" size={8} style={{ width: '100%', marginTop: 8 }}>
<Typography.Text>{t('setup.nodeRestartDesc')}</Typography.Text>
<CopyCode value={restartCmd} />
</Space>
}
/>
<Alert
type="info"