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:
Debian
2026-05-29 16:07:19 +02:00
parent a0b3bccbc6
commit 7e5a7a83c0
10 changed files with 782 additions and 359 deletions

View File

@@ -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>
)