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
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user