feat(monorepo): restructure web components and update configurations
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
|
||||
|
||||
type Props = {
|
||||
enabled?: boolean;
|
||||
onchange?: (enabled: boolean) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
enabled = $bindable(readNetworkAutoRefresh()),
|
||||
onchange,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
function onToggle(checked: boolean) {
|
||||
enabled = checked;
|
||||
writeNetworkAutoRefresh(checked);
|
||||
onchange?.(checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
|
||||
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
|
||||
Авто (~15 с)
|
||||
</Label>
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { loadSettings, partitionSettings } from '$lib/settings/settings-api.js';
|
||||
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Local IPv4',
|
||||
bird_local_ipv6: 'Local IPv6',
|
||||
bird_local_asn: 'Local ASN',
|
||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||
bird_bgp_source_ipv6: 'BGP source IPv6'
|
||||
};
|
||||
|
||||
let loading = $state(true);
|
||||
let values = $state<Record<string, string>>({});
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of BIRD_SETTING_KEYS) {
|
||||
const v = String(partitioned.bird[key] ?? '').trim();
|
||||
if (v) out[key] = v;
|
||||
}
|
||||
values = out;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BIRD (кратко)</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD из tenant settings. Полная форма — в разделе «Параметры».
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if Object.keys(values).length === 0}
|
||||
<p class="text-sm text-muted-foreground">Параметры BIRD ещё не заданы.</p>
|
||||
{:else}
|
||||
<dl class="grid gap-2 text-sm sm:grid-cols-2">
|
||||
{#each Object.entries(values) as [key, value] (key)}
|
||||
<div class="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<dt class="text-muted-foreground">{labels[key] ?? key}</dt>
|
||||
<dd class="font-mono text-xs break-all">{value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" href={resolve('/tenant-settings?tab=bird')}>
|
||||
<SlidersHorizontal class="size-4" />
|
||||
Изменить параметры
|
||||
<ArrowRight class="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
aggregateNetworkMetrics,
|
||||
collectNetworkIssues,
|
||||
deriveNetworkOverallStatus,
|
||||
networkOverallStatusHint,
|
||||
networkOverallStatusLabel
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
|
||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
import GitBranch from '@lucide/svelte/icons/git-branch';
|
||||
import Activity from '@lucide/svelte/icons/activity';
|
||||
import Bird from '@lucide/svelte/icons/bird';
|
||||
import Gauge from '@lucide/svelte/icons/gauge';
|
||||
|
||||
type Props = {
|
||||
peers: PeerRow[];
|
||||
speakers: SpeakerRow[];
|
||||
bird: BirdStatus | null;
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
peers,
|
||||
speakers,
|
||||
bird,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
onSpeakerSelect
|
||||
}: Props = $props();
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-3',
|
||||
bg: 'bg-chart-3/5',
|
||||
iconBg: 'bg-chart-3/15',
|
||||
iconText: 'text-chart-3'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
},
|
||||
{
|
||||
border: 'border-l-warning',
|
||||
bg: 'bg-warning/5',
|
||||
iconBg: 'bg-warning/15',
|
||||
iconText: 'text-warning'
|
||||
},
|
||||
{
|
||||
border: 'border-l-destructive',
|
||||
bg: 'bg-destructive/5',
|
||||
iconBg: 'bg-destructive/15',
|
||||
iconText: 'text-destructive'
|
||||
},
|
||||
{
|
||||
border: 'border-l-info',
|
||||
bg: 'bg-info/10',
|
||||
iconBg: 'bg-info/15',
|
||||
iconText: 'text-info'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
|
||||
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
|
||||
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
|
||||
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
|
||||
|
||||
const birdText = $derived.by(() => {
|
||||
if (!bird?.birdc_configured) return '—';
|
||||
if (bird.error) return '—';
|
||||
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||
});
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'peers',
|
||||
label: 'BGP-пиры',
|
||||
value: initialLoading ? '—' : String(metrics.peersTotal),
|
||||
description: initialLoading
|
||||
? ''
|
||||
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
|
||||
icon: Share2,
|
||||
accent: statAccents[0],
|
||||
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
|
||||
badgeClass:
|
||||
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
|
||||
},
|
||||
{
|
||||
id: 'established',
|
||||
label: 'Активные сессии',
|
||||
value: initialLoading ? '—' : String(metrics.peersEstablished),
|
||||
description: 'Established среди включённых пиров',
|
||||
icon: CheckCircle2,
|
||||
accent: statAccents[1],
|
||||
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
|
||||
badgeClass:
|
||||
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
label: 'Спикеры online',
|
||||
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
|
||||
description: 'agent + BGP poll',
|
||||
icon: Server,
|
||||
accent: statAccents[2],
|
||||
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
|
||||
badgeClass:
|
||||
metrics.speakersOnline === metrics.speakersTotal
|
||||
? 'border-success/30 bg-success/15 text-success'
|
||||
: 'border-warning/30 bg-warning/15 text-warning'
|
||||
},
|
||||
{
|
||||
id: 'drift',
|
||||
label: 'Drift',
|
||||
value: initialLoading ? '—' : String(metrics.speakersDrift),
|
||||
description: 'applied ≠ published',
|
||||
icon: GitBranch,
|
||||
accent: statAccents[3],
|
||||
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
|
||||
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
|
||||
},
|
||||
{
|
||||
id: 'poll-errors',
|
||||
label: 'Ошибки опроса',
|
||||
value: initialLoading ? '—' : String(metrics.pollErrors),
|
||||
description: 'agent или BGP poll',
|
||||
icon: Activity,
|
||||
accent: statAccents[4],
|
||||
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
|
||||
badgeClass:
|
||||
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
|
||||
},
|
||||
{
|
||||
id: 'cp-bird',
|
||||
label: 'BGP на CP',
|
||||
value: initialLoading ? '—' : birdText,
|
||||
description: bird?.birdc_configured
|
||||
? 'Established / total на API-хосте'
|
||||
: (bird?.message ?? 'birdc не настроен'),
|
||||
icon: Bird,
|
||||
accent: statAccents[5],
|
||||
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
|
||||
href: '/monitoring' as const
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-6">
|
||||
{#if !initialLoading && !loading}
|
||||
{#if overallStatus === 'ok'}
|
||||
<Alert class="border-success/30 bg-success/5">
|
||||
<CheckCircle class="text-success" />
|
||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription>{overallHint}</AlertDescription>
|
||||
</Alert>
|
||||
{:else if overallStatus === 'warn'}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<AlertTriangle class="text-warning" />
|
||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{overallHint}
|
||||
{#if issues.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc text-sm">
|
||||
{#each issues as issue (issue.id)}
|
||||
<li>{issue.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{:else}
|
||||
<Alert variant="destructive">
|
||||
<XCircle />
|
||||
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{overallHint}
|
||||
{#if issues.length > 0}
|
||||
<ul class="mt-2 list-inside list-disc text-sm">
|
||||
{#each issues as issue (issue.id)}
|
||||
<li>{issue.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<KpiMetricsGrid
|
||||
cards={kpiCards}
|
||||
loading={initialLoading || loading}
|
||||
skeletonCount={6}
|
||||
class="sm:grid-cols-2 xl:grid-cols-3"
|
||||
/>
|
||||
|
||||
<section class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-base font-semibold">Ноды</h2>
|
||||
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
|
||||
<Gauge class="size-3.5" />
|
||||
Мониторинг API
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if speakers.length === 0 && !initialLoading && !loading}
|
||||
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
|
||||
{:else}
|
||||
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{#each speakers as speaker (speaker.id)}
|
||||
<NetworkSpeakerStatusCard
|
||||
{speaker}
|
||||
{peers}
|
||||
class="h-full"
|
||||
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,347 @@
|
||||
<script lang="ts">
|
||||
import { apiMutate } from '$lib/api/client.js';
|
||||
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import { Label } from '@evobgp/ui/components/label/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '@evobgp/ui/components/dialog/index.js';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger
|
||||
} from '@evobgp/ui/components/select/index.js';
|
||||
import { Switch } from '@evobgp/ui/components/switch/index.js';
|
||||
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
type Props = {
|
||||
items: PeerRow[];
|
||||
speakers: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
speakers,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null,
|
||||
onRefresh
|
||||
}: Props = $props();
|
||||
|
||||
let dialogOpen = $state(false);
|
||||
let editTarget = $state<PeerRow | null>(null);
|
||||
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
|
||||
name: '',
|
||||
neighbor: '',
|
||||
remote_asn: 0,
|
||||
bgp_speaker_id: null,
|
||||
enabled: true
|
||||
});
|
||||
let saving = $state(false);
|
||||
let toggleId = $state<string | null>(null);
|
||||
|
||||
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
|
||||
|
||||
const columns = [
|
||||
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
|
||||
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
|
||||
{
|
||||
id: 'remote_asn',
|
||||
label: 'Remote ASN',
|
||||
sortable: true,
|
||||
sortValue: (p: PeerRow) => p.remote_asn ?? 0
|
||||
},
|
||||
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
|
||||
{ id: 'session_state', label: 'Состояние сессии' },
|
||||
{ id: 'speaker', label: 'Спикер' },
|
||||
{ id: 'actions', label: '', class: 'w-20' }
|
||||
] as const;
|
||||
|
||||
function peerNodeLine(s: PeerSessionOnSpeaker): string {
|
||||
if (s.poll_error) return `${s.label}: опрос недоступен`;
|
||||
if (s.state === 'Established') return `${s.label}: Established`;
|
||||
if (s.state === 'absent') return `${s.label}: нет сессии`;
|
||||
return `${s.label}: ${s.state || '—'}`;
|
||||
}
|
||||
|
||||
function peerConnectedLabel(p: PeerRow): string {
|
||||
const nodes = p.session_on_speakers ?? [];
|
||||
if (nodes.length > 0) {
|
||||
return nodes.map(peerNodeLine).join(' · ');
|
||||
}
|
||||
const established = p.established_on_speakers ?? [];
|
||||
if (established.length > 0) {
|
||||
return established.map((s) => `${s.label}: Established`).join(' · ');
|
||||
}
|
||||
return 'Не найден на опрошенных нодах';
|
||||
}
|
||||
|
||||
function peerSessionHint(p: PeerRow): string | null {
|
||||
if (!p.session_mismatch || !p.bgp_speaker_id) return null;
|
||||
const expected = speakerLabelById(p.bgp_speaker_id);
|
||||
const actual =
|
||||
p.established_on_speakers?.map((s) => s.label).join(', ') ||
|
||||
p.connected_speaker_label?.trim() ||
|
||||
'другие ноды';
|
||||
return `В конфиге: ${expected}; Established на: ${actual}`;
|
||||
}
|
||||
|
||||
function speakerLabelById(id: string | null | undefined) {
|
||||
if (!id) return 'Все спикеры';
|
||||
const s = speakerById.get(id);
|
||||
if (!s) return id.slice(0, 8) + '…';
|
||||
if (s.role === 'master') {
|
||||
const host = s.agent_domain ?? s.endpoint;
|
||||
return host ? `CP · ${host}` : 'CP (master)';
|
||||
}
|
||||
return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…';
|
||||
}
|
||||
|
||||
function sessionBadge(
|
||||
state: string,
|
||||
p: PeerRow
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (p.session_mismatch) return 'destructive';
|
||||
if (state === 'Established') return 'default';
|
||||
if (state === 'Active' || state === 'Connect') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editTarget = null;
|
||||
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(p: PeerRow) {
|
||||
editTarget = p;
|
||||
form = {
|
||||
name: p.name ?? '',
|
||||
neighbor: p.neighbor,
|
||||
remote_asn: p.remote_asn ?? 0,
|
||||
bgp_speaker_id: p.bgp_speaker_id,
|
||||
enabled: p.enabled !== false
|
||||
};
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(p: PeerRow) {
|
||||
void confirm({
|
||||
title: 'Удалить пира?',
|
||||
description: p.neighbor,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Пир удалён');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function setEnabled(p: PeerRow, enabled: boolean) {
|
||||
toggleId = p.id;
|
||||
try {
|
||||
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
|
||||
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
toggleId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.neighbor.trim()) {
|
||||
notify.error('Укажите адрес соседа');
|
||||
return;
|
||||
}
|
||||
if (!form.remote_asn || form.remote_asn <= 0) {
|
||||
notify.error('Remote ASN должен быть больше 0');
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
|
||||
notify.success('Пир обновлён');
|
||||
} else {
|
||||
await apiMutate('/v1/peers', 'POST', {
|
||||
...form,
|
||||
enabled: form.enabled !== false
|
||||
});
|
||||
notify.success('Пир создан');
|
||||
}
|
||||
dialogOpen = false;
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</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">BGP-пиры</CardTitle>
|
||||
<CardDescription>Настройка BGP-соседей и привязка к спикерам</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={(p) => p.id}
|
||||
loading={initialLoading || loading}
|
||||
{error}
|
||||
emptyTitle="Нет BGP-пиров"
|
||||
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
|
||||
>
|
||||
{#snippet cell({ row: p, column })}
|
||||
{#if column.id === 'name'}
|
||||
<span>{p.name?.trim() || '—'}</span>
|
||||
{:else if column.id === 'neighbor'}
|
||||
<span class="font-mono text-sm">{p.neighbor}</span>
|
||||
{:else if column.id === 'remote_asn'}
|
||||
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
|
||||
{:else if column.id === 'enabled'}
|
||||
<div class="flex justify-center">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={p.enabled !== false}
|
||||
disabled={loading || toggleId === p.id}
|
||||
onCheckedChange={(v) => setEnabled(p, v)}
|
||||
/>
|
||||
</div>
|
||||
{:else if column.id === 'session_state'}
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
|
||||
<span
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
|
||||
>
|
||||
{peerConnectedLabel(p)}
|
||||
</span>
|
||||
{#if peerSessionHint(p)}
|
||||
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'speaker'}
|
||||
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
|
||||
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => requestDelete(p)}
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog bind:open={dialogOpen}>
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
|
||||
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-4 py-2">
|
||||
<FormField label="Имя пира (опционально)" id="p-name">
|
||||
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
|
||||
</FormField>
|
||||
<FormField label="Адрес соседа" id="p-neighbor" required>
|
||||
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
|
||||
</FormField>
|
||||
<FormField label="Remote ASN" id="p-asn" required>
|
||||
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
|
||||
</FormField>
|
||||
<FormField label="Спикер (опционально)" id="p-speaker">
|
||||
<Select
|
||||
type="single"
|
||||
value={form.bgp_speaker_id ?? ''}
|
||||
onValueChange={(v) => {
|
||||
form = { ...form, bgp_speaker_id: v || null };
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="p-speaker" class="w-full">
|
||||
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Не выбрано</SelectItem>
|
||||
{#each speakers as s (s.id)}
|
||||
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||
<p class="text-xs leading-snug text-muted-foreground">
|
||||
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="p-enabled"
|
||||
class="shrink-0"
|
||||
checked={form.enabled !== false}
|
||||
onCheckedChange={(v) => {
|
||||
form = { ...form, enabled: v };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
||||
<Button onclick={save} disabled={saving}>
|
||||
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script lang="ts">
|
||||
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
peersForSpeaker,
|
||||
speakerDisplayStatus,
|
||||
speakerDispatchError,
|
||||
speakerHasDrift,
|
||||
speakerLabel,
|
||||
speakerLiveAgentError,
|
||||
speakerLiveBgpError
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import { Separator } from '@evobgp/ui/components/separator/index.js';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@evobgp/ui/components/sheet/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@evobgp/ui/components/table/index.js';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
|
||||
type Props = {
|
||||
speaker: SpeakerRow | null;
|
||||
peers: PeerRow[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onApply?: (speaker: SpeakerRow) => void;
|
||||
};
|
||||
|
||||
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
|
||||
|
||||
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
|
||||
const label = $derived(speaker ? speakerLabel(speaker) : '');
|
||||
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
|
||||
const sessions = $derived(speaker?.live?.sessions ?? []);
|
||||
const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
|
||||
const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
|
||||
const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
|
||||
|
||||
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 formatSyncAt(iso: string | undefined): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
onOpenChange?.(open);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sheet bind:open>
|
||||
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
|
||||
{#if speaker}
|
||||
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
|
||||
<SheetHeader class="space-y-1 pr-8 text-left">
|
||||
<SheetTitle class="truncate">{label}</SheetTitle>
|
||||
<SheetDescription class="truncate">
|
||||
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if status}
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
{/if}
|
||||
{#if speakerHasDrift(speaker)}
|
||||
<Badge variant="secondary">Drift</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">BGP Established</dt>
|
||||
<dd class="text-right font-medium tabular-nums">
|
||||
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
|
||||
</dd>
|
||||
{#if speaker.live?.agent_last_sync_at}
|
||||
<dt class="text-muted-foreground">Последний sync</dt>
|
||||
<dd class="text-right text-xs tabular-nums">
|
||||
{formatSyncAt(speaker.live.agent_last_sync_at)}
|
||||
</dd>
|
||||
{/if}
|
||||
<dt class="text-muted-foreground">Drift (app / pub)</dt>
|
||||
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
|
||||
{#if speaker.last_dispatch_at}
|
||||
<dt class="text-muted-foreground">Dispatch</dt>
|
||||
<dd class="text-right text-xs tabular-nums">
|
||||
{formatSyncAt(speaker.last_dispatch_at)}
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
{#if dispatchError}
|
||||
<Alert class="border-warning/30 bg-warning/5">
|
||||
<AlertTriangle class="text-warning" />
|
||||
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
|
||||
<AlertDescription class="text-xs leading-relaxed"
|
||||
>{dispatchError.detail}</AlertDescription
|
||||
>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if agentError}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
|
||||
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if bgpError}
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
|
||||
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if onApply && speaker.published_revision_id}
|
||||
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
|
||||
Apply revision
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Separator />
|
||||
|
||||
<section class="min-w-0 space-y-2">
|
||||
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
|
||||
{:else}
|
||||
<div class="rounded-md border">
|
||||
<Table class="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[65%]">Имя</TableHead>
|
||||
<TableHead class="w-[35%] text-right">Состояние</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each sessions as sess, i (sess.name + i)}
|
||||
<TableRow>
|
||||
<TableCell class="align-top">
|
||||
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
|
||||
{#if sess.neighbor}
|
||||
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
|
||||
{sess.neighbor}
|
||||
</p>
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-right align-top">
|
||||
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section class="min-w-0 space-y-2">
|
||||
<h3 class="text-sm font-medium">Пиры на ноде</h3>
|
||||
{#if relatedPeers.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
|
||||
{:else}
|
||||
<ul class="divide-y rounded-md border">
|
||||
{#each relatedPeers as p (p.id)}
|
||||
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
|
||||
{p.name?.trim() || p.neighbor}
|
||||
</p>
|
||||
{#if p.session_mismatch}
|
||||
<p class="mt-0.5 text-xs text-warning">
|
||||
Mismatch: сессия не на назначенной ноде
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
|
||||
import {
|
||||
speakerBgpText,
|
||||
speakerDisplayStatus,
|
||||
speakerHasDrift,
|
||||
speakerLabel
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
|
||||
type Props = {
|
||||
speaker: SpeakerRow;
|
||||
peers?: PeerRow[];
|
||||
onclick?: () => void;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
let { speaker, peers = [], onclick, class: className }: Props = $props();
|
||||
|
||||
const status = $derived(speakerDisplayStatus(speaker));
|
||||
const label = $derived(speakerLabel(speaker));
|
||||
const drift = $derived(speakerHasDrift(speaker));
|
||||
const peerCount = $derived(
|
||||
peers.filter(
|
||||
(p) =>
|
||||
p.bgp_speaker_id === speaker.id ||
|
||||
p.bgp_speaker_id === null ||
|
||||
p.bgp_speaker_id === undefined
|
||||
).length
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<Card
|
||||
class={cn(
|
||||
'flex h-full flex-col transition-colors',
|
||||
onclick ? 'cursor-pointer hover:border-primary/35' : '',
|
||||
className
|
||||
)}
|
||||
role={onclick ? 'button' : undefined}
|
||||
tabindex={onclick ? 0 : undefined}
|
||||
{onclick}
|
||||
onkeydown={(e) => {
|
||||
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onclick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="flex items-center gap-2 text-sm">
|
||||
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="truncate" title={label}>{label}</span>
|
||||
</CardTitle>
|
||||
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
|
||||
</div>
|
||||
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="mt-auto pt-0">
|
||||
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">BGP</dt>
|
||||
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
|
||||
<dt class="text-muted-foreground">Пиры</dt>
|
||||
<dd class="tabular-nums">{peerCount}</dd>
|
||||
<dt class="text-muted-foreground">Drift</dt>
|
||||
<dd>
|
||||
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
|
||||
{drift ? 'есть' : 'нет'}
|
||||
</Badge>
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,526 @@
|
||||
<script lang="ts">
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
|
||||
import {
|
||||
speakerBgpText,
|
||||
speakerDisplayStatus,
|
||||
speakerHasDrift
|
||||
} from '$lib/network/network-metrics.js';
|
||||
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
||||
import { Button } from '@evobgp/ui/components/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '@evobgp/ui/components/card/index.js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription
|
||||
} from '@evobgp/ui/components/dialog/index.js';
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
|
||||
import FormField from '$lib/components/patterns/form/form-field.svelte';
|
||||
import AppInput from '$lib/components/patterns/form/app-input.svelte';
|
||||
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
||||
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
||||
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';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
|
||||
type Props = {
|
||||
items: SpeakerRow[];
|
||||
loading?: boolean;
|
||||
initialLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
onSpeakerSelect?: (speaker: SpeakerRow) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
loading = false,
|
||||
initialLoading = false,
|
||||
error = null,
|
||||
onRefresh,
|
||||
onSpeakerSelect
|
||||
}: 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: 'live_agent', label: 'Agent' },
|
||||
{ id: 'bgp', label: 'BGP' },
|
||||
{
|
||||
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-44' }
|
||||
] 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' {
|
||||
return speakerDisplayStatus(s).variant;
|
||||
}
|
||||
|
||||
function statusLabel(s: SpeakerRow): string {
|
||||
return speakerDisplayStatus(s).label;
|
||||
}
|
||||
|
||||
function liveAgentLabel(s: SpeakerRow): string {
|
||||
if (!s.live) return '—';
|
||||
if (s.live.agent_ok === true) return 'OK';
|
||||
return s.live.agent_error ? 'Error' : 'Offline';
|
||||
}
|
||||
|
||||
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 requestDelete(s: SpeakerRow) {
|
||||
const label = s.agent_domain ?? s.endpoint ?? s.id;
|
||||
void confirm({
|
||||
title: 'Удалить спикера?',
|
||||
description: label,
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false });
|
||||
notify.success('Спикер удалён');
|
||||
await onRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
[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 {
|
||||
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 === 'live_agent'}
|
||||
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
|
||||
{:else if column.id === 'bgp'}
|
||||
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
|
||||
{: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"
|
||||
class:text-warning={speakerHasDrift(s)}
|
||||
>
|
||||
{driftLabel(s)}
|
||||
</span>
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#if onSpeakerSelect}
|
||||
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
|
||||
<Eye class="size-3" />
|
||||
</Button>
|
||||
{/if}
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
title="Удалить спикера"
|
||||
onclick={() => requestDelete(s)}
|
||||
>
|
||||
<Trash2 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>
|
||||
Reference in New Issue
Block a user