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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user