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.
465 lines
14 KiB
Svelte
465 lines
14 KiB
Svelte
<script lang="ts">
|
|
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 {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription
|
|
} from '$lib/ui/core/card/index.js';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
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';
|
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
|
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[];
|
|
loading?: boolean;
|
|
initialLoading?: boolean;
|
|
error?: string | null;
|
|
onRefresh: () => void | Promise<void>;
|
|
};
|
|
|
|
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 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: 'agent_domain',
|
|
label: 'Agent domain',
|
|
sortable: true,
|
|
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
|
|
},
|
|
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
|
|
{ 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 = emptyForm();
|
|
dialogOpen = true;
|
|
}
|
|
|
|
function openEdit(s: SpeakerRow) {
|
|
editTarget = s;
|
|
form = formFromSpeaker(s);
|
|
dialogOpen = true;
|
|
}
|
|
|
|
function openApply(s: SpeakerRow) {
|
|
applyTarget = s;
|
|
applyRevisionId = s.published_revision_id ?? '';
|
|
applyDialogOpen = true;
|
|
}
|
|
|
|
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
|
|
let pubkey = '';
|
|
try {
|
|
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
|
|
LETSENCRYPT_EMAIL=ops@example.com
|
|
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 {
|
|
applyingId = null;
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
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', body);
|
|
notify.success('Спикер обновлён');
|
|
dialogOpen = false;
|
|
} else {
|
|
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
|
|
notify.success('Спикер создан');
|
|
dialogOpen = false;
|
|
createdSpeaker = created;
|
|
composeText = await buildComposeSnippet(created);
|
|
wizardOpen = true;
|
|
}
|
|
await onRefresh();
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
} finally {
|
|
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>
|
|
<CardHeader
|
|
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<CardTitle class="text-base">Спикеры</CardTitle>
|
|
<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>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="p-4 pt-0">
|
|
<AppDataTable
|
|
columns={[...columns]}
|
|
rows={items}
|
|
rowKey={(s) => s.id}
|
|
loading={initialLoading || loading}
|
|
{error}
|
|
emptyTitle="Нет спикеров"
|
|
emptyDescription="Добавьте реплику для применения signed bundle."
|
|
>
|
|
{#snippet cell({ row: s, column })}
|
|
{#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 === 'drift'}
|
|
<span class="font-mono text-xs text-muted-foreground" title="applied / published">
|
|
{driftLabel(s)}
|
|
</span>
|
|
{:else if column.id === 'actions'}
|
|
<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="Apply revision (canary)"
|
|
onclick={() => openApply(s)}
|
|
disabled={applyingId === s.id}
|
|
>
|
|
<Play class="size-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
|
|
<Pencil class="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</AppDataTable>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog bind:open={dialogOpen}>
|
|
<DialogContent class="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
|
|
</DialogHeader>
|
|
<div class="space-y-4 py-2">
|
|
<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="replica" bind:value={form.role} />
|
|
</FormField>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
|
<Button onclick={save} disabled={saving}>
|
|
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
|
</Button>
|
|
</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>
|