feat(cluster): FQDN-First Token-Flow — Node vor dem Join vorregistrieren

Cluster-Seite: Schritt 2 fragt jetzt den FQDN des neuen Knotens bevor
der Token generiert wird. Der Knoten wird sofort in ha_nodes (status=pending)
eingetragen. Nach Token-Generierung wird direkt die Setup-Wizard-URL des
neuen Knotens angezeigt (https://<fqdn>:3443/setup).

Backend: POST /cluster/join-tokens nimmt jetzt node_fqdn entgegen,
pre-registriert via preRegisterByFQDN(). Die IP wird beim issue-cert
nachgetragen (preRegisterJoiner).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 17:29:58 +02:00
parent fd69081336
commit 5ace250879
8 changed files with 110 additions and 39 deletions

View File

@@ -67,6 +67,7 @@ interface JoinTokenResponse {
token: string
expires_at: string
ca_fingerprint: string
node_fqdn?: string
}
interface CertInfo {
@@ -260,9 +261,10 @@ export default function ClusterPage() {
})
const [joinToken, setJoinToken] = useState<JoinTokenResponse | null>(null)
const [nodeFqdnInput, setNodeFqdnInput] = useState('')
const generateToken = useMutation({
mutationFn: async () => {
const r = await apiClient.post('/cluster/join-tokens')
mutationFn: async (nodeFqdn: string) => {
const r = await apiClient.post('/cluster/join-tokens', { node_fqdn: nodeFqdn })
return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null
},
onSuccess: (tok) => { setJoinToken(tok) },
@@ -270,9 +272,6 @@ export default function ClusterPage() {
})
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
const joinCmd = joinToken
? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}`
: ''
const peerColumns: ColumnsType<HANode> = [
{
@@ -416,17 +415,34 @@ export default function ClusterPage() {
<StepRow n={2} title={t('cluster.step2Title')}>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step2Desc')}</Paragraph>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
type="primary"
icon={<KeyOutlined />}
loading={generateToken.isPending}
disabled={isViewer || (isError && !data)}
onClick={() => generateToken.mutate()}
>
{t('cluster.generateJoinToken')}
{!joinToken ? (
<Space.Compact style={{ width: '100%', maxWidth: 480 }}>
<Input
placeholder="eg2.example.com"
value={nodeFqdnInput}
onChange={(e) => setNodeFqdnInput(e.target.value)}
onPressEnter={() => {
if (nodeFqdnInput.trim() && !isViewer) generateToken.mutate(nodeFqdnInput.trim())
}}
disabled={isViewer}
/>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
type="primary"
icon={<KeyOutlined />}
loading={generateToken.isPending}
disabled={isViewer || !nodeFqdnInput.trim() || (isError && !data)}
onClick={() => generateToken.mutate(nodeFqdnInput.trim())}
>
{t('cluster.generateJoinToken')}
</Button>
</Tooltip>
</Space.Compact>
) : (
<Button size="small" onClick={() => { setJoinToken(null); setNodeFqdnInput('') }}>
{t('cluster.generateNewToken')}
</Button>
</Tooltip>
)}
</StepRow>
<StepRow n={3} title={t('cluster.step3Title')}>
@@ -440,7 +456,24 @@ export default function ClusterPage() {
expires: new Date(joinToken.expires_at).toLocaleString(),
})}
/>
{joinToken.node_fqdn && (
<Alert
type="info"
showIcon
message={t('cluster.setupWizardHint')}
description={
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
{'https://' + joinToken.node_fqdn + ':3443/setup'}
</Text>
}
/>
)}
<Descriptions size="small" bordered column={1}>
{joinToken.node_fqdn && (
<Descriptions.Item label={t('cluster.newNodeFqdnLabel')}>
<Text style={{ fontFamily: 'monospace' }}>{joinToken.node_fqdn}</Text>
</Descriptions.Item>
)}
<Descriptions.Item label={t('cluster.tokenLabel')}>
<Text style={{ fontFamily: 'monospace', fontSize: 11, wordBreak: 'break-all' }}>
{joinToken.token}
@@ -452,12 +485,9 @@ export default function ClusterPage() {
</Descriptions.Item>
)}
</Descriptions>
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
{t('cluster.joinCmdLabel')}
</Text>
<CopyCode value={joinCmd} />
</div>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{t('cluster.step3SetupDesc', { primaryFqdn })}
</Paragraph>
</Space>
) : (
<Text type="secondary">{t('cluster.step3NoToken')}</Text>
@@ -465,10 +495,8 @@ export default function ClusterPage() {
</StepRow>
<StepRowLast n={4} title={t('cluster.step4Title')}>
<Paragraph type="secondary" style={{ marginBottom: 8 }}>{t('cluster.step4Desc')}</Paragraph>
<CopyCode value="sudo systemctl restart edgeguard-api" />
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
{t('cluster.step4Desc')}
</Paragraph>
</StepRowLast>
</Card>