feat: add BIRD status endpoint and integrate birdc checks into job processing. Enhance UI to display BIRD runtime status and job metadata, improving user feedback on BIRD operations.
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 25s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m7s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m5s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 16s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m0s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m25s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m30s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m30s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m27s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m13s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m32s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m23s

This commit is contained in:
Denozordec
2026-04-06 00:25:43 +07:00
parent e1aaa18377
commit 94a4c6acd4
8 changed files with 324 additions and 8 deletions
+19
View File
@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import type { JobRow } from './types.js';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
@@ -103,3 +104,21 @@ async function parseResponse<T>(res: Response): Promise<T> {
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled']);
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
export async function waitForJob(
jobId: string,
opts?: { pollMs?: number; timeoutMs?: number }
): Promise<JobRow> {
const pollMs = opts?.pollMs ?? 400;
const timeoutMs = opts?.timeoutMs ?? 120000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`);
if (terminalJobStatuses.has(j.status)) return j;
await new Promise((r) => setTimeout(r, pollMs));
}
throw new Error(`Таймаут ожидания задачи ${jobId}`);
}
+11
View File
@@ -178,6 +178,17 @@ export type RevisionDiff = {
[key: string]: unknown;
};
// ---- BIRD (локальный birdc на хосте с API, если задан EVOBGP_BIRDC_SOCKET) ----
export type BirdStatus = {
birdc_configured: boolean;
message?: string;
error?: string;
protocols_excerpt?: string;
bgp_sessions_total: number;
bgp_established: number;
healthy: boolean | null;
};
// ---- Jobs ----
export type JobRow = {
job_id: string;
+147 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import { apiJSON, apiMutate, waitForJob } from '$lib/api/client.js';
import type {
BirdStatus,
RevisionRow,
RevisionsResponse,
RevisionPrefixesResponse,
@@ -100,6 +101,37 @@
let applyConfirm = $state(false);
let reloadConfirm = $state(false);
// BIRD runtime status (birdc на хосте API, если настроен сокет)
let birdStatus = $state<BirdStatus | null>(null);
let birdLoading = $state(false);
let birdProtocolsOpen = $state(false);
function summarizeJobBirdMeta(job: JobRow): string {
const check = job.meta?.bird_post_apply_check;
if (check === 'skipped_no_birdc_socket') {
return 'Проверка birdc пропущена (на API не задан EVOBGP_BIRDC_SOCKET).';
}
const m = job.meta?.bird_post_apply as Record<string, unknown> | undefined;
if (!m) return '';
if (m.ok === false) return `Проверка birdc: ${String(m.error ?? 'ошибка')}`;
if (m.ok === true) {
return `Проверка birdc: BGP Established ${String(m.bgp_established ?? '—')}/${String(m.bgp_sessions_total ?? '—')}.`;
}
return '';
}
async function loadBirdStatus() {
birdLoading = true;
try {
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
} catch (e) {
birdStatus = null;
toast.error(e instanceof Error ? e.message : String(e));
} finally {
birdLoading = false;
}
}
async function loadRevisions() {
revLoading = true;
try {
@@ -124,7 +156,11 @@
}
}
onMount(() => { loadRevisions(); loadJobs(); });
onMount(() => {
loadRevisions();
loadJobs();
loadBirdStatus();
});
async function openPreview(rev: RevisionRow) {
previewRevision = rev;
@@ -184,10 +220,27 @@
toast.error('Нет ревизий — сначала refresh модуля или дождитесь задачи render');
return;
}
await apiMutate('/v1/apply', 'POST', { revision_id: revId });
toast.success('Apply запущен на всех спикерах');
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
revision_id: revId
});
applyConfirm = false;
if (!res?.job_id) {
toast.error('Ответ API без job_id');
return;
}
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') {
toast.success(extra ? `Apply успешно. ${extra}` : 'Apply успешно завершён');
} else {
toast.error(
job.error
? `${job.status}: ${job.error}`
: `Задача завершилась со статусом ${job.status}`
);
}
await loadJobs();
await loadBirdStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
@@ -198,9 +251,25 @@
async function doBirdReload() {
reloading = true;
try {
await apiMutate('/v1/bird/reload', 'POST', {});
toast.success('BIRD reload запущен');
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
reloadConfirm = false;
if (!res?.job_id) {
toast.error('Ответ API без job_id');
return;
}
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
const extra = summarizeJobBirdMeta(job);
if (job.status === 'succeeded') {
toast.success(extra ? `Reload успешно. ${extra}` : 'birdc configure выполнен');
} else {
toast.error(
job.error
? `${job.status}: ${job.error}`
: `Задача завершилась со статусом ${job.status}`
);
}
await loadJobs();
await loadBirdStatus();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
@@ -243,6 +312,18 @@
if (!d) return '—';
return new Date(d).toLocaleString('ru');
}
function birdHealthyBadgeVariant(h: boolean | null | undefined): 'default' | 'secondary' | 'outline' | 'destructive' {
if (h === true) return 'default';
if (h === false) return 'destructive';
return 'outline';
}
function birdHealthyShortLabel(h: boolean | null | undefined): string {
if (h === true) return 'OK';
if (h === false) return 'Проблема';
return 'Н/Д';
}
</script>
<div class="space-y-6">
@@ -251,8 +332,8 @@
<p class="text-muted-foreground mt-1 text-sm">Деплой конфигурации, управление ревизиями и задачами.</p>
</div>
<!-- Quick actions -->
<div class="grid gap-3 sm:grid-cols-2">
<!-- Quick actions + локальный статус BIRD (хост API, если задан EVOBGP_BIRDC_SOCKET) -->
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Card class="p-4">
<div class="flex items-center justify-between">
<div>
@@ -277,6 +358,51 @@
</Button>
</div>
</Card>
<Card class="p-4 sm:col-span-2 xl:col-span-1">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<Bird class="text-muted-foreground size-4 shrink-0" />
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
{/if}
</div>
<p class="text-muted-foreground text-sm">
Локально на хосте API: <code class="bg-muted rounded px-1 text-xs">birdc show protocols</code>. Не заменяет мониторинг
спикеров.
</p>
{#if birdLoading}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if birdStatus}
{#if !birdStatus.birdc_configured}
<p class="text-muted-foreground text-sm">{birdStatus.message ?? 'birdc не настроен на API.'}</p>
{:else if birdStatus.error}
<p class="text-destructive text-sm">{birdStatus.error}</p>
{:else}
<p class="text-sm">
<span class="text-muted-foreground">BGP сессий:</span>
<span class="font-medium">{birdStatus.bgp_established}</span>
<span class="text-muted-foreground">/</span>
<span class="font-medium">{birdStatus.bgp_sessions_total}</span>
<span class="text-muted-foreground"> Established / всего</span>
</p>
{/if}
{:else}
<p class="text-muted-foreground text-sm">Статус не загружен</p>
{/if}
</div>
<div class="flex shrink-0 flex-wrap gap-2 sm:flex-col sm:items-stretch">
<Button variant="outline" size="sm" onclick={loadBirdStatus} disabled={birdLoading}>
<RefreshCw class={birdLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
Обновить
</Button>
{#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
<Button variant="ghost" size="sm" onclick={() => (birdProtocolsOpen = true)}>Вывод birdc</Button>
{/if}
</div>
</div>
</Card>
</div>
<Tabs value="revisions">
@@ -574,6 +700,19 @@
</DialogContent>
</Dialog>
<!-- BIRD protocols excerpt -->
<Dialog bind:open={birdProtocolsOpen}>
<DialogContent class="flex max-h-[min(90vh,720px)] flex-col gap-3 sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Вывод birdc (протоколы)</DialogTitle>
<DialogDescription>Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере.</DialogDescription>
</DialogHeader>
<ScrollArea class="border-border bg-muted/40 max-h-[min(60vh,480px)] rounded-md border">
<pre class="m-0 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words select-text">{birdStatus?.protocols_excerpt ?? ''}</pre>
</ScrollArea>
</DialogContent>
</Dialog>
<!-- Job detail dialog -->
<Dialog bind:open={jobDetailDialog}>
<DialogContent class="sm:max-w-lg">