From 3723ba7ed1b6141cbd97391e5085228f516d59c7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 21 Aug 2026 13:51:06 +0700 Subject: [PATCH] feat(httpapi): return replica docker install commands on speaker create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После создания реплики 201 отдаёт agent_secret, node_token и install.docker_commands (bird2 + agent + Traefik DNS-01). UI показывает шаг установки вместо закрытия диалога, чтобы секрет больше не терялся. Co-authored-by: Cursor --- .../network/network-speakers-card.tsx | 5 +- .../network/speaker-form-dialog.tsx | 279 ++++++++++++++---- apps/web/src/lib/ui-labels.ts | 3 +- apps/web/src/types/api.ts | 13 +- docs/openapi.yaml | 58 +++- docs/remote-speakers.md | 88 +++--- internal/httpapi/routes_crud.go | 38 ++- internal/httpapi/speakers.go | 88 ++++++ internal/httpapi/speakers_test.go | 111 +++++++ internal/speakerinstall/install.go | 254 ++++++++++++++++ internal/speakerinstall/install_test.go | 66 +++++ 11 files changed, 891 insertions(+), 112 deletions(-) create mode 100644 internal/speakerinstall/install.go create mode 100644 internal/speakerinstall/install_test.go diff --git a/apps/web/src/components/network/network-speakers-card.tsx b/apps/web/src/components/network/network-speakers-card.tsx index 8b2ee07..62c087e 100644 --- a/apps/web/src/components/network/network-speakers-card.tsx +++ b/apps/web/src/components/network/network-speakers-card.tsx @@ -36,9 +36,8 @@ const SPEAKER_TABS = [ ] const ROLE_OPTIONS = [ - { value: 'primary', label: 'Основной' }, - { value: 'secondary', label: 'Резервный' }, - { value: 'speaker', label: 'Спикер' }, + { value: 'replica', label: 'Реплика' }, + { value: 'master', label: 'Мастер' }, ] function createDefaultSpeakerFilters(): Filter[] { diff --git a/apps/web/src/components/network/speaker-form-dialog.tsx b/apps/web/src/components/network/speaker-form-dialog.tsx index 6b0a4f9..4809253 100644 --- a/apps/web/src/components/network/speaker-form-dialog.tsx +++ b/apps/web/src/components/network/speaker-form-dialog.tsx @@ -1,15 +1,19 @@ import { useEffect, useState } from 'react' +import { Copy, TriangleAlert } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@evobgp/ui/components/button' import { Input } from '@evobgp/ui/components/input' import { Label } from '@evobgp/ui/components/label' +import { Textarea } from '@evobgp/ui/components/textarea' import { FormDrawer } from '@/components/form-drawer' import { LoadingButton } from '@/components/loading-button' import { SelectField } from '@/components/select-field' +import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCreateSpeakerMutation } from '@/queries/network' -import type { BgpSpeakerCreate } from '@/types/api' +import type { BgpSpeakerCreate, SpeakerRow } from '@/types/api' interface SpeakerFormDialogProps { open: boolean @@ -35,8 +39,20 @@ function buildMetaJson(agentDomain: string, nodeIpv4: string, bgpSource: string) return JSON.stringify(meta) } +function tlsIncomplete( + agentDomain: string, + letsencryptEmail: string, + cfToken: string, + panelIP: string, +): boolean { + return !agentDomain.trim() || !letsencryptEmail.trim() || !cfToken.trim() || !panelIP.trim() +} + export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps) { const createMutation = useCreateSpeakerMutation() + const { isCopied, copyToClipboard } = useCopyToClipboard({ + onCopy: () => toast.success('Команда скопирована'), + }) const [endpoint, setEndpoint] = useState('') const [role, setRole] = useState('replica') @@ -44,6 +60,13 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps const [nodeIpv4, setNodeIpv4] = useState('') const [bgpSourceIpv4, setBgpSourceIpv4] = useState('') const [bgpSourceManual, setBgpSourceManual] = useState(false) + const [letsencryptEmail, setLetsencryptEmail] = useState('') + const [cfDnsToken, setCfDnsToken] = useState('') + const [panelIP, setPanelIP] = useState('') + const [created, setCreated] = useState(null) + + const isReplica = role === 'replica' + const installCommands = created?.install?.docker_commands ?? '' useEffect(() => { if (!open) return @@ -53,6 +76,10 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps setNodeIpv4('') setBgpSourceIpv4('') setBgpSourceManual(false) + setLetsencryptEmail('') + setCfDnsToken('') + setPanelIP('') + setCreated(null) }, [open]) function handleEndpointChange(value: string) { @@ -81,82 +108,210 @@ export function SpeakerFormDialog({ open, onOpenChange }: SpeakerFormDialogProps endpoint: ep, role: role.trim() || 'replica', meta_json: buildMetaJson(agentDomain, nodeIpv4, bgpSourceIpv4), + control_plane_url: window.location.origin, + } + if (isReplica) { + if (letsencryptEmail.trim()) body.letsencrypt_email = letsencryptEmail.trim() + if (cfDnsToken.trim()) body.cf_dns_api_token = cfDnsToken.trim() + if (panelIP.trim()) body.panel_ip_whitelist = panelIP.trim() } try { - await createMutation.mutateAsync(body) + const row = await createMutation.mutateAsync(body) + if (row.install?.docker_commands) { + setCreated(row) + return + } onOpenChange(false) } catch { // toast in mutation } } + const showingInstall = created !== null && Boolean(installCommands) + return ( - - - Создать - - + showingInstall ? ( + <> + + + + ) : ( + <> + + + Создать + + + ) } > -
- - handleEndpointChange(e.target.value)} - /> -
- setRole(v ?? 'replica')} - /> -
- - setAgentDomain(e.target.value)} - /> -
-
- - handleNodeIpv4Change(e.target.value)} - /> -
-
- - { - setBgpSourceManual(true) - setBgpSourceIpv4(e.target.value) - }} - /> -
+ {created && installCommands ? ( + + ) : ( + <> +
+ + handleEndpointChange(e.target.value)} + /> +
+ setRole(v ?? 'replica')} + /> +
+ + setAgentDomain(e.target.value)} + /> +
+
+ + handleNodeIpv4Change(e.target.value)} + /> +
+
+ + { + setBgpSourceManual(true) + setBgpSourceIpv4(e.target.value) + }} + /> +
+ {isReplica ? ( + <> +
+ + setLetsencryptEmail(e.target.value)} + /> +
+
+ + setCfDnsToken(e.target.value)} + /> +
+
+ + setPanelIP(e.target.value)} + /> +
+ {tlsIncomplete(agentDomain, letsencryptEmail, cfDnsToken, panelIP) ? ( + + + Traefik не выпустит сертификат + + Нужны домен агента, email LE, Cloudflare token и IP панели. Иначе в + команде останутся плейсхолдеры CHANGE_ME_*. + + + ) : null} + + ) : null} + + )}
) } + +function SpeakerInstallStep({ + created, + commands, +}: { + created: SpeakerRow + commands: string +}) { + return ( + <> + + + Сохраните сейчас + + agent_secret и node_token больше не будут показаны. Traefik на ноде выпускает + сертификат через DNS-01 (Cloudflare). + + +
+ + {created.id} +
+ {created.agent_secret ? ( +
+ + {created.agent_secret} +
+ ) : null} + {created.node_token ? ( +
+ + {created.node_token} +
+ ) : null} +
+ +