diff --git a/internal/birdfmt/local_status.go b/internal/birdfmt/local_status.go new file mode 100644 index 0000000..2469fa1 --- /dev/null +++ b/internal/birdfmt/local_status.go @@ -0,0 +1,55 @@ +package birdfmt + +import ( + "context" + "os" + "strings" +) + +// LocalBirdStatus is returned by GET /v1/bird/status (same host as birdc when socket is configured). +type LocalBirdStatus struct { + BirdcConfigured bool `json:"birdc_configured"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + ProtocolsExcerpt string `json:"protocols_excerpt,omitempty"` + BGPSessionsTotal int `json:"bgp_sessions_total"` + BGPEstablished int `json:"bgp_established"` + // Healthy: null if birdc not configured; false if birdc failed or BGP sessions exist but none Established; true otherwise. + Healthy *bool `json:"healthy"` +} + +// InspectLocalBird runs `birdc show protocols all` using EVOBGP_BIRDC_SOCKET / EVOBGP_BIRDC_BIN. +func InspectLocalBird(ctx context.Context) LocalBirdStatus { + sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")) + if sock == "" { + return LocalBirdStatus{ + BirdcConfigured: false, + Message: "EVOBGP_BIRDC_SOCKET не задан на этом процессе — статус BIRD недоступен (типично, если birdc только на ноде со спикером).", + Healthy: nil, + } + } + bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")) + out, err := ShowProtocols(ctx, sock, bin) + if err != nil { + f := false + return LocalBirdStatus{ + BirdcConfigured: true, + Error: err.Error(), + Healthy: &f, + } + } + sum := SummarizeProtocolsOutput(out) + excerpt := out + const maxExcerpt = 20000 + if len(excerpt) > maxExcerpt { + excerpt = excerpt[:maxExcerpt] + "\n# … truncated …\n" + } + ok := sum.BGPSessionsTotal == 0 || sum.BGPEstablished > 0 + return LocalBirdStatus{ + BirdcConfigured: true, + ProtocolsExcerpt: excerpt, + BGPSessionsTotal: sum.BGPSessionsTotal, + BGPEstablished: sum.BGPEstablished, + Healthy: &ok, + } +} diff --git a/internal/birdfmt/protocol_summary.go b/internal/birdfmt/protocol_summary.go new file mode 100644 index 0000000..931136e --- /dev/null +++ b/internal/birdfmt/protocol_summary.go @@ -0,0 +1,36 @@ +package birdfmt + +import ( + "strings" +) + +// ProtocolsSummary is a lightweight parse of `birdc show protocols all` (BIRD 2). +type ProtocolsSummary struct { + BGPSessionsTotal int + BGPEstablished int + RawLineCount int +} + +// SummarizeProtocolsOutput extracts BGP session heuristics from birdc output. +func SummarizeProtocolsOutput(output string) ProtocolsSummary { + var s ProtocolsSummary + lines := strings.Split(output, "\n") + s.RawLineCount = len(lines) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + low := strings.ToLower(line) + if strings.HasPrefix(low, "name") || strings.HasPrefix(low, "table") { + continue + } + if strings.Contains(low, "bgp") { + s.BGPSessionsTotal++ + if strings.Contains(low, "established") { + s.BGPEstablished++ + } + } + } + return s +} diff --git a/internal/birdfmt/protocol_summary_test.go b/internal/birdfmt/protocol_summary_test.go new file mode 100644 index 0000000..d7c512d --- /dev/null +++ b/internal/birdfmt/protocol_summary_test.go @@ -0,0 +1,14 @@ +package birdfmt + +import "testing" + +func TestSummarizeProtocolsOutput(t *testing.T) { + sample := `name proto table state since info +device1 Device --- up 10:00:00 +uplink BGP --- start 10:00:01 Established +` + s := SummarizeProtocolsOutput(sample) + if s.BGPSessionsTotal != 1 || s.BGPEstablished != 1 { + t.Fatalf("got %+v", s) + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index f5ccb32..fd0cfce 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "evobgp/internal/birdfmt" "evobgp/internal/bundle" "evobgp/internal/jobs" "evobgp/internal/observability" @@ -58,6 +59,7 @@ func (s *Server) registerV1(m *http.ServeMux) { m.HandleFunc("POST /apply", s.handleApply) m.HandleFunc("POST /speakers/{id}/apply", s.handleSpeakerApply) m.HandleFunc("POST /bird/reload", s.handleBirdReload) + m.HandleFunc("GET /bird/status", s.handleBirdStatus) m.HandleFunc("GET /jobs", s.handleListJobs) m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob) m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob) @@ -555,6 +557,21 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, map[string]any{"job_id": j.ID, "status": "queued"}) } +func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok { + writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth") + return + } + if !s.requireAtLeast(w, a, "viewer") { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + st := birdfmt.InspectLocalBird(ctx) + writeJSON(w, http.StatusOK, st) +} + func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok { diff --git a/internal/jobs/worker.go b/internal/jobs/worker.go index e68283b..2f900e3 100644 --- a/internal/jobs/worker.go +++ b/internal/jobs/worker.go @@ -14,6 +14,28 @@ import ( "evobgp/internal/store" ) +// mergeBirdPostApplyMeta attaches a birdc snapshot after deploy/reload (best-effort). +func mergeBirdPostApplyMeta(j *Job) { + if strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")) == "" { + j.mergeMeta(map[string]any{"bird_post_apply_check": "skipped_no_birdc_socket"}) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + st := birdfmt.InspectLocalBird(ctx) + inner := map[string]any{ + "bgp_established": st.BGPEstablished, + "bgp_sessions_total": st.BGPSessionsTotal, + } + if st.Error != "" { + inner["ok"] = false + inner["error"] = st.Error + } else { + inner["ok"] = true + } + j.mergeMeta(map[string]any{"bird_post_apply": inner}) +} + const ( KindModuleRefresh = "module_refresh" KindDeployApply = "deploy_apply" @@ -85,6 +107,7 @@ func (w *Worker) Process(j *Job) { j.Fail(err.Error()) return } + mergeBirdPostApplyMeta(j) j.Succeed() default: j.Fail("unknown job kind") @@ -137,6 +160,7 @@ func (w *Worker) runDeployApply(j *Job) { j.Fail(err.Error()) return } + mergeBirdPostApplyMeta(j) j.Succeed() return } @@ -146,6 +170,7 @@ func (w *Worker) runDeployApply(j *Job) { return } } + mergeBirdPostApplyMeta(j) j.Succeed() } diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 06758af..fac96b0 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -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(res: Response): Promise { 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 { + const pollMs = opts?.pollMs ?? 400; + const timeoutMs = opts?.timeoutMs ?? 120000; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const j = await apiJSON(`/v1/jobs/${jobId}`); + if (terminalJobStatuses.has(j.status)) return j; + await new Promise((r) => setTimeout(r, pollMs)); + } + throw new Error(`Таймаут ожидания задачи ${jobId}`); +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 4d9fa15..236f652 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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; diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 112eee6..4305972 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -1,7 +1,8 @@
@@ -251,8 +332,8 @@

Деплой конфигурации, управление ревизиями и задачами.

- -
+ +
@@ -277,6 +358,51 @@
+ +
+
+
+ +

Состояние BIRD

+ {#if birdStatus} + {birdHealthyShortLabel(birdStatus.healthy)} + {/if} +
+

+ Локально на хосте API: birdc show protocols. Не заменяет мониторинг + спикеров. +

+ {#if birdLoading} +

Загрузка…

+ {:else if birdStatus} + {#if !birdStatus.birdc_configured} +

{birdStatus.message ?? 'birdc не настроен на API.'}

+ {:else if birdStatus.error} +

{birdStatus.error}

+ {:else} +

+ BGP сессий: + {birdStatus.bgp_established} + / + {birdStatus.bgp_sessions_total} + Established / всего +

+ {/if} + {:else} +

Статус не загружен

+ {/if} +
+
+ + {#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt} + + {/if} +
+
+
@@ -574,6 +700,19 @@ + + + + + Вывод birdc (протоколы) + Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. + + +
{birdStatus?.protocols_excerpt ?? ''}
+
+
+
+