feat(remote-speakers): enhance remote speaker management and API integration
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Failing after 34s
CI / go (push) Failing after 19s
CI / bird2 (push) Has been skipped
CI / release (push) Has been skipped

- Added support for remote speaker configuration in the README and documentation.
- Implemented a new endpoint for retrieving the bundle signing public key.
- Updated the `evobgp-agent` to include a `serve` command for Panel→Node sync API.
- Enhanced CI workflow to validate remote speaker compose files.
- Introduced new fields in the API and UI for managing speaker metadata, including dispatch status and sync status.
- Improved error handling and response formatting in speaker-related API endpoints.
- Updated documentation to reflect changes in remote speaker functionality and usage guidelines.
This commit is contained in:
Denozordec
2026-05-21 12:42:06 +07:00
parent ec65249bf1
commit 2aecbf96fd
29 changed files with 2003 additions and 63 deletions
+16
View File
@@ -177,14 +177,30 @@ export type SpeakerRow = {
role: string;
endpoint: string;
last_applied_revision_id: string | null;
published_revision_id?: string | null;
published_at?: string | null;
agent_domain?: string;
node_ipv4?: string;
bird_bgp_source_ipv4?: string;
dispatch_status?: string;
sync_status?: string;
last_dispatch_at?: string | null;
last_dispatch_error?: string | null;
meta_json?: Record<string, unknown>;
agent_secret?: string;
};
export type SpeakersResponse = Page<SpeakerRow>;
export type BgpSpeakerCreate = {
endpoint: string;
role?: string;
meta_json?: string;
};
export type BgpSpeakerPatch = Partial<BgpSpeakerCreate>;
export type BundleSigningPublicKey = {
public_key_base64: string;
};
// ---- Revisions ----
export type RevisionRow = {
id: string;
@@ -1,6 +1,6 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
@@ -15,8 +15,10 @@
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
@@ -24,6 +26,7 @@
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
import Copy from '@lucide/svelte/icons/copy';
type Props = {
items: SpeakerRow[];
@@ -35,41 +38,209 @@
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
type SpeakerForm = {
endpoint: string;
role: string;
agent_domain: string;
node_ipv4: string;
bird_bgp_source_ipv4: string;
bgpSourceManual: boolean;
};
let dialogOpen = $state(false);
let wizardOpen = $state(false);
let applyDialogOpen = $state(false);
let composeDialogOpen = $state(false);
let editTarget = $state<SpeakerRow | null>(null);
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let applyTarget = $state<SpeakerRow | null>(null);
let composeTarget = $state<SpeakerRow | null>(null);
let applyRevisionId = $state('');
let composeText = $state('');
let createdSpeaker = $state<SpeakerRow | null>(null);
let form = $state<SpeakerForm>({
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
});
let saving = $state(false);
let applyingId = $state<string | null>(null);
const columns = [
{ id: 'status', label: 'Статус' },
{
id: 'endpoint',
label: 'Endpoint',
id: 'agent_domain',
label: 'Agent domain',
sortable: true,
sortValue: (s: SpeakerRow) => s.endpoint
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
{ id: 'actions', label: '', class: 'w-32' }
{ id: 'drift', label: 'Drift' },
{ id: 'actions', label: '', class: 'w-40' }
] as const;
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
const host = u.hostname;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
} catch {
/* ignore */
}
return '';
}
function onNodeIPv4Change(ip: string) {
form.node_ipv4 = ip;
if (!form.bgpSourceManual) {
form.bird_bgp_source_ipv4 = ip;
}
}
function onEndpointChange(ep: string) {
form.endpoint = ep;
const ip = parseIpv4FromEndpoint(ep);
if (ip && !form.node_ipv4) {
onNodeIPv4Change(ip);
}
}
function emptyForm(): SpeakerForm {
return {
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
};
}
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
return {
endpoint: s.endpoint,
role: s.role,
agent_domain: s.agent_domain ?? '',
node_ipv4: s.node_ipv4 ?? '',
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
bgpSourceManual: Boolean(s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4)
};
}
function buildMetaJson(f: SpeakerForm): string {
const meta: Record<string, string> = {};
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
return JSON.stringify(meta);
}
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
const ep =
f.endpoint.trim() ||
(f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
return {
endpoint: ep,
role: f.role.trim() || 'replica',
meta_json: buildMetaJson(f)
};
}
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
if (s.sync_status === 'synced' || s.dispatch_status === 'ok') return 'default';
if (s.sync_status === 'error' || s.dispatch_status === 'error') return 'destructive';
return 'outline';
}
function statusLabel(s: SpeakerRow): string {
if (s.sync_status === 'synced') return 'Connected';
if (s.sync_status === 'error' || s.last_dispatch_error) return 'Offline';
if (s.dispatch_status === 'ok') return 'Synced';
return 'Unknown';
}
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function openCreate() {
editTarget = null;
form = { endpoint: '', role: 'operator' };
form = emptyForm();
dialogOpen = true;
}
function openEdit(s: SpeakerRow) {
editTarget = s;
form = { endpoint: s.endpoint, role: s.role };
form = formFromSpeaker(s);
dialogOpen = true;
}
async function applySpeaker(id: string) {
applyingId = id;
function openApply(s: SpeakerRow) {
applyTarget = s;
applyRevisionId = s.published_revision_id ?? '';
applyDialogOpen = true;
}
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
let pubkey = '';
try {
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
pubkey = pk.public_key_base64;
} catch {
pubkey = '<GET /v1/bundle/signing-public-key>';
}
const domain = s.agent_domain ?? 'bgp-dc.example.com';
return `# deploy/compose/docker-compose.remote-speaker.yaml
# cp .env.remote-speaker.example .env.remote-speaker
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
EVOBGP_SPEAKER_ID=${s.id}
EVOBGP_AGENT_SECRET=<from UI wizard>
EVOBGP_NODE_TOKEN=<node API key from /access>
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
AGENT_DOMAIN=${domain}
PANEL_IP_WHITELIST=<CP public IP>/32
[email protected]
CF_DNS_API_TOKEN=<cloudflare token>
# docker compose -f docker-compose.remote-speaker.yaml \\
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
# --profile production up -d`;
}
async function openCompose(s: SpeakerRow) {
composeTarget = s;
composeText = await buildComposeSnippet(s);
composeDialogOpen = true;
}
async function copyCompose() {
try {
await navigator.clipboard.writeText(composeText);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
async function applySpeaker() {
if (!applyTarget || !applyRevisionId.trim()) {
notify.error('Укажите revision_id');
return;
}
applyingId = applyTarget.id;
try {
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
revision_id: applyRevisionId.trim()
});
notify.success('Apply запущен');
applyDialogOpen = false;
} catch (e) {
notifyApiError(e);
} finally {
@@ -78,20 +249,25 @@
}
async function save() {
if (!form.endpoint.trim()) {
notify.error('Укажите endpoint');
const body = buildApiBody(form);
if (!body.endpoint.trim()) {
notify.error('Укажите endpoint или agent domain');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
notify.success('Спикер обновлён');
dialogOpen = false;
} else {
await apiMutate('/v1/speakers', 'POST', form);
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
notify.success('Спикер создан');
dialogOpen = false;
createdSpeaker = created;
composeText = await buildComposeSnippet(created);
wizardOpen = true;
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
@@ -99,6 +275,17 @@
saving = false;
}
}
async function copyAgentSecret() {
const secret = createdSpeaker?.agent_secret;
if (!secret) return;
try {
await navigator.clipboard.writeText(secret);
notify.success('agent_secret скопирован');
} catch {
notify.error('Не удалось скопировать');
}
}
</script>
<Card>
@@ -107,7 +294,7 @@
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</CardDescription>
<CardDescription>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
@@ -121,28 +308,32 @@
loading={initialLoading || loading}
{error}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте BIRD-агент для применения конфигурации."
emptyDescription="Добавьте реплику для применения signed bundle."
>
{#snippet cell({ row: s, column })}
{#if column.id === 'endpoint'}
<span class="font-mono text-sm">{s.endpoint}</span>
{#if column.id === 'status'}
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
{:else if column.id === 'agent_domain'}
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'last_applied_revision_id'}
<span class="font-mono text-xs text-muted-foreground">
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
{:else if column.id === 'drift'}
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
{driftLabel(s)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<div class="flex flex-wrap gap-1">
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
<Copy class="size-3" />
</Button>
<Button
variant="outline"
size="xs"
title="Запустить применение ревизии на спикере"
onclick={() => applySpeaker(s.id)}
title="Apply revision (canary)"
onclick={() => openApply(s)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'}
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil class="size-3.5" />
@@ -155,16 +346,48 @@
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Endpoint" id="s-endpoint" required>
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
<FormField label="Agent domain (FQDN)" id="s-domain">
<AppInput
id="s-domain"
placeholder="bgp-dc2.example.com"
bind:value={form.agent_domain}
/>
</FormField>
<FormField label="Endpoint" id="s-endpoint">
<AppInput
id="s-endpoint"
placeholder="https://bgp-dc2.example.com"
value={form.endpoint}
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="IP ноды (IPv4)" id="s-node-ip">
<AppInput
id="s-node-ip"
placeholder="203.0.113.10"
value={form.node_ipv4}
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="BGP source IPv4" id="s-bgp-src">
<AppInput
id="s-bgp-src"
placeholder="= IP ноды"
bind:value={form.bird_bgp_source_ipv4}
disabled={!form.bgpSourceManual}
/>
</FormField>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.bgpSourceManual} />
Задать BGP source вручную
</label>
<FormField label="Роль" id="s-role">
<AppInput id="s-role" placeholder="operator" bind:value={form.role} />
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
</FormField>
</div>
<DialogFooter>
@@ -175,3 +398,67 @@
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={wizardOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Спикер создан</DialogTitle>
<DialogDescription>
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
</DialogDescription>
</DialogHeader>
{#if createdSpeaker?.agent_secret}
<FormField label="agent_secret (один раз)" id="w-secret">
<div class="flex gap-2">
<AppInput id="w-secret" readonly value={createdSpeaker.agent_secret} class="font-mono text-xs" />
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
</div>
</FormField>
{/if}
<FormField label="docker-compose env" id="w-compose">
<textarea
id="w-compose"
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
</FormField>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={applyDialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Apply на спикер</DialogTitle>
</DialogHeader>
<FormField label="revision_id" id="a-rev" required>
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
</FormField>
<DialogFooter>
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={composeDialogOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Copy docker-compose</DialogTitle>
<DialogDescription>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription>
</DialogHeader>
<textarea
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
</DialogFooter>
</DialogContent>
</Dialog>