feat: add @tanstack/table-core dependency and integrate module name mapping in operations page. Update OperationsJobsTab to utilize new job kind title functionality and enhance job detail presentation with improved layout and overflow handling.
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 43s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m2s
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-prime (push) Successful in 23s
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 2m12s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m22s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m18s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m21s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m5s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m25s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m29s

This commit is contained in:
Denozordec
2026-04-06 23:45:19 +07:00
parent b541794d84
commit b140dcbcf2
11 changed files with 800 additions and 151 deletions
+14
View File
@@ -8,6 +8,7 @@
"name": "web",
"version": "0.0.1",
"dependencies": {
"@tanstack/table-core": "^8.21.3",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
@@ -1258,6 +1259,19 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+2 -1
View File
@@ -14,13 +14,13 @@
"format": "prettier --write ."
},
"devDependencies": {
"@types/node": "^22.15.0",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.7.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22.15.0",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
@@ -31,6 +31,7 @@
"vite": "^7.3.1"
},
"dependencies": {
"@tanstack/table-core": "^8.21.3",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
@@ -4,17 +4,27 @@
import type { JobDetailedReport, JobLogEntry } from './types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card/index.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import { cn } from '$lib/utils.js';
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
import JobReportTableBlock from './job-report-table-block.svelte';
import { asnReportColumns, reportRowColumns } from './job-report-columns.js';
import type { RowData } from '@tanstack/table-core';
type Props = {
jobs: JobRow[];
jobsLoading: boolean;
moduleNameById: ReadonlyMap<string, string>;
expandedJobIds: SvelteSet<string>;
jobDetailsById: SvelteMap<string, JobRow>;
jobDetailsLoading: SvelteSet<string>;
@@ -34,6 +44,7 @@
let {
jobs,
jobsLoading,
moduleNameById,
expandedJobIds,
jobDetailsById,
jobDetailsLoading,
@@ -49,6 +60,12 @@
jobStatusVariant,
formatDate
}: Props = $props();
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
RowData,
unknown
>[];
const asnCols = asnReportColumns as import('@tanstack/table-core').ColumnDef<RowData, unknown>[];
</script>
<Card>
@@ -57,34 +74,56 @@
<CardTitle class="text-base">Задачи</CardTitle>
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
</div>
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={onReloadJobs} disabled={jobsLoading}>
<Button
variant="outline"
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onReloadJobs}
disabled={jobsLoading}
>
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="space-y-3 p-3 sm:p-4 max-h-[78vh] overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
<CardContent class="space-y-3 p-3 sm:p-4">
{#each jobs as job (job.job_id)}
<div class="bg-card min-w-0 overflow-hidden rounded-lg border">
{@const kindSub = jobKindSubtitle(job, moduleNameById)}
<div class="min-w-0 overflow-hidden rounded-lg border bg-card">
<div class="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div class="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex items-center gap-2">
<div class="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
class="text-muted-foreground hover:text-foreground mt-0.5 shrink-0"
aria-label={isJobExpanded(job.job_id) ? 'Свернуть детали задачи' : 'Развернуть детали задачи'}
class="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isJobExpanded(job.job_id)
? 'Свернуть детали задачи'
: 'Развернуть детали задачи'}
aria-expanded={isJobExpanded(job.job_id)}
onclick={() => void onToggleJobExpanded(job)}
>
<ChevronDown class={cn('size-4 transition-transform', isJobExpanded(job.job_id) && 'rotate-180')} />
<ChevronDown
class={cn(
'size-4 transition-transform',
isJobExpanded(job.job_id) && 'rotate-180'
)}
/>
</Button>
<div class="min-w-0">
<p class="font-medium break-words">{job.kind}</p>
<p class="text-muted-foreground font-mono text-xs break-all">{job.job_id}</p>
<p class="font-medium break-words">{jobKindTitle(job, moduleNameById)}</p>
{#if kindSub}
<p class="font-mono text-xs break-all text-muted-foreground">{kindSub}</p>
{/if}
<p class="font-mono text-xs break-all text-muted-foreground">{job.job_id}</p>
</div>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenJobDetail(job)} aria-label="Открыть задачу в модальном окне">
<Button
variant="ghost"
size="icon-sm"
onclick={() => onOpenJobDetail(job)}
aria-label="Открыть задачу в модальном окне"
>
<Eye class="size-3.5" />
</Button>
{#if job.status === 'running' || job.status === 'queued'}
@@ -103,19 +142,19 @@
<div class="grid gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-muted-foreground text-[11px] uppercase">Статус</p>
<p class="text-[11px] text-muted-foreground uppercase">Статус</p>
<Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge>
</div>
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-muted-foreground text-[11px] uppercase">Создана</p>
<p class="text-[11px] text-muted-foreground uppercase">Создана</p>
<p>{formatDate(job.created_at)}</p>
</div>
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-muted-foreground text-[11px] uppercase">Запущена</p>
<p class="text-[11px] text-muted-foreground uppercase">Запущена</p>
<p>{formatDate(job.started_at)}</p>
</div>
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-muted-foreground text-[11px] uppercase">Завершена</p>
<p class="text-[11px] text-muted-foreground uppercase">Завершена</p>
<p>{formatDate(job.finished_at)}</p>
</div>
</div>
@@ -128,16 +167,16 @@
{@const jobReport = jobReportsById.get(job.job_id)}
{@const logEntries = getJobLogEntries(detailedJob)}
{@const logTotal = getJobLogTotal(detailedJob, logEntries)}
<div class="border-t bg-muted/10 p-3 sm:p-4 max-h-[70vh] overflow-y-auto overscroll-contain">
<div class="border-t bg-muted/10 p-3 sm:p-4">
<div class="space-y-3 pr-1">
{#if isDetailsLoading}
<p class="text-muted-foreground text-xs">Догружаем свежие детали задачи…</p>
<p class="text-xs text-muted-foreground">Догружаем свежие детали задачи…</p>
{/if}
{#if detailedJob.error}
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p class="text-muted-foreground mb-1 text-xs">error</p>
<p class="text-destructive break-words text-sm">{detailedJob.error}</p>
<p class="mb-1 text-xs text-muted-foreground">error</p>
<p class="text-sm break-words text-destructive">{detailedJob.error}</p>
</div>
{/if}
@@ -145,54 +184,94 @@
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Журнал обработки</p>
<p class="text-muted-foreground rounded-md border bg-muted/50 px-2 py-0.5 text-xs">
<p
class="rounded-md border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground"
>
{logEntries.length} записей, всего {logTotal} префиксов
</p>
</div>
<ScrollArea class="max-h-[24rem] rounded-md border bg-muted/10 p-2">
<div class="rounded-md border bg-muted/10 p-2">
<div class="space-y-2">
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
<div class="bg-card space-y-3 rounded-lg border border-border/70 p-3 shadow-sm">
<p class="text-sm leading-relaxed font-medium break-words">{entry.message}</p>
<div
class="space-y-3 rounded-lg border border-border/70 bg-card p-3 shadow-sm"
>
<p class="text-sm leading-relaxed font-medium break-words">
{entry.message}
</p>
<div class="grid gap-2 md:grid-cols-2">
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">source</span>
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.source}</p>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>source</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{entry.source}
</p>
</div>
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">kind</span>
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.kind}</p>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>kind</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{entry.kind}
</p>
</div>
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">community</span>
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.community}</p>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>community</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{entry.community}
</p>
</div>
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">prefixes</span>
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs">{entry.prefix_count}</p>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>prefixes</span
>
<p class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs">
{entry.prefix_count}
</p>
</div>
</div>
{#if entry.sample && entry.sample.length > 0}
<div class="space-y-1.5">
<p class="text-muted-foreground text-[11px] uppercase tracking-wide">sample</p>
<ScrollArea class="max-h-28 rounded-md border bg-muted/35 p-2.5">
<p class="text-[11px] tracking-wide text-muted-foreground uppercase">
sample
</p>
<div class="rounded-md border bg-muted/35 p-2.5">
<div class="space-y-1.5">
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
<p class="bg-background rounded px-2 py-1 font-mono text-xs break-all">{sampleValue}</p>
<p
class="rounded bg-background px-2 py-1 font-mono text-xs break-all"
>
{sampleValue}
</p>
{/each}
</div>
</ScrollArea>
</div>
</div>
{/if}
</div>
{/each}
</div>
</ScrollArea>
</div>
</div>
{/if}
{#if isReportLoading}
<p class="text-muted-foreground text-xs">Собираем подробный отчёт по источникам и агрегации…</p>
<p class="text-xs text-muted-foreground">
Собираем подробный отчёт по источникам и агрегации…
</p>
{/if}
{#if jobReport}
@@ -205,31 +284,35 @@
<Badge variant="secondary">{jobReport.module.name}</Badge>
{/if}
{#if jobReport.revisionId}
<Badge variant="outline" class="font-mono text-[10px]">{jobReport.revisionId.slice(0, 8)}…</Badge>
<Badge variant="outline" class="font-mono text-[10px]"
>{jobReport.revisionId.slice(0, 8)}…</Badge
>
{/if}
</div>
</div>
<div class="grid min-w-0 gap-2 md:grid-cols-2 xl:grid-cols-4">
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">Агрегация</p>
<p class="text-[11px] text-muted-foreground uppercase">Агрегация</p>
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
</div>
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">Домены</p>
<p class="text-[11px] text-muted-foreground uppercase">Домены</p>
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
</div>
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">ASN</p>
<p class="text-[11px] text-muted-foreground uppercase">ASN</p>
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
</div>
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
<p class="text-muted-foreground text-[11px] uppercase">CDN/IP Range</p>
<p class="text-sm font-semibold">{jobReport.cdn.length}/{jobReport.ipRanges.length}</p>
<p class="text-[11px] text-muted-foreground uppercase">CDN/IP Range</p>
<p class="text-sm font-semibold">
{jobReport.cdn.length}/{jobReport.ipRanges.length}
</p>
</div>
</div>
{#if jobReport.aggregationByKind.length > 0}
<div class="space-y-1">
<p class="text-muted-foreground text-xs">Результат агрегации по типам</p>
<p class="text-xs text-muted-foreground">Результат агрегации по типам</p>
<div class="flex flex-wrap gap-1.5">
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge>
@@ -239,77 +322,65 @@
{/if}
<div class="grid min-w-0 gap-3 xl:grid-cols-2">
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">Домены: какой домен какие IP/префиксы вернул</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
<div class="mb-2 rounded border bg-background p-2">
<p class="font-mono text-xs break-all">{row.label}</p>
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по доменам</p>
{/each}
</ScrollArea>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
Домены: какой домен какие IP/префиксы вернул
</p>
<JobReportTableBlock
rows={jobReport.domains as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по доменам"
/>
</div>
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
<div class="mb-2 flex items-center justify-between rounded border bg-background p-2">
<span class="font-mono text-xs">AS{row.asn}</span>
<Badge variant="outline">{row.prefixCount}</Badge>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по ASN</p>
{/each}
</ScrollArea>
<JobReportTableBlock
rows={jobReport.asn as RowData[]}
columns={asnCols}
emptyLabel="Нет данных по ASN"
/>
</div>
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">CDN: из каждой ссылки полученные IP/префиксы</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
<div class="mb-2 rounded border bg-background p-2">
<p class="font-mono text-xs break-all">{row.label}</p>
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по CDN</p>
{/each}
</ScrollArea>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
CDN: из каждой ссылки полученные IP/префиксы
</p>
<JobReportTableBlock
rows={jobReport.cdn as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по CDN"
/>
</div>
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">IP range: итог по статическим диапазонам</p>
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
{#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
<div class="mb-2 rounded border bg-background p-2">
<p class="font-mono text-xs break-all">{row.label}</p>
<p class="text-muted-foreground text-[11px]">{row.prefixCount} диапазонов/префиксов</p>
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
</div>
{:else}
<p class="text-muted-foreground text-xs">Нет данных по IP range</p>
{/each}
</ScrollArea>
<JobReportTableBlock
rows={jobReport.ipRanges as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по IP range"
/>
</div>
</div>
</div>
{/if}
<div class="space-y-1">
<p class="text-muted-foreground text-xs">Meta (raw JSON)</p>
<ScrollArea class="max-h-56 rounded-md border bg-muted/30 p-3">
<pre class="font-mono text-xs whitespace-pre-wrap [overflow-wrap:anywhere]">{JSON.stringify(detailedJob.meta ?? {}, null, 2)}</pre>
</ScrollArea>
<p class="text-xs text-muted-foreground">Meta (raw JSON)</p>
<div class="rounded-md border bg-muted/30 p-3">
<pre
class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify(
detailedJob.meta ?? {},
null,
2
)}</pre>
</div>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div class="text-muted-foreground py-10 text-center text-sm">{jobsLoading ? 'Загрузка…' : 'Нет задач'}</div>
<div class="text-muted-foreground py-10 text-center text-sm">
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
</div>
{/each}
</CardContent>
</Card>
@@ -0,0 +1,42 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { ReportRow } from './types.js';
export const reportRowColumns: ColumnDef<ReportRow>[] = [
{
accessorKey: 'label',
header: 'Метка',
cell: ({ row }) => {
const v = row.original.label;
return typeof v === 'string' ? v : '—';
}
},
{
accessorKey: 'prefixCount',
header: 'Префиксов',
cell: ({ row }) => String(row.original.prefixCount)
},
{
id: 'prefixes',
header: 'Префиксы',
cell: ({ row }) => {
const p = row.original.prefixes;
if (!p?.length) return '—';
return p.join(', ');
}
}
];
export type AsnReportRow = { asn: string; prefixCount: number };
export const asnReportColumns: ColumnDef<AsnReportRow>[] = [
{
accessorKey: 'asn',
header: 'ASN',
cell: ({ row }) => `AS${row.original.asn}`
},
{
accessorKey: 'prefixCount',
header: 'Префиксов',
cell: ({ row }) => String(row.original.prefixCount)
}
];
@@ -0,0 +1,107 @@
<script lang="ts">
import {
type ColumnDef,
type PaginationState,
type RowData,
getCoreRowModel,
getPaginationRowModel
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button/index.js';
type Props = {
rows: RowData[];
columns: ColumnDef<RowData, unknown>[];
emptyLabel: string;
};
let { rows, columns, emptyLabel }: Props = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
const table = createSvelteTable({
get data() {
return rows;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pagination;
}
},
onPaginationChange: (updater) => {
pagination = typeof updater === 'function' ? updater(pagination) : updater;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class="max-w-[min(28rem,40vw)]">
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell
class="max-w-[min(28rem,40vw)] align-top font-mono text-xs [overflow-wrap:anywhere] break-all whitespace-pre-wrap"
>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell
colspan={columns.length}
class="text-muted-foreground h-16 text-center text-sm"
>
{emptyLabel}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if table.getPageCount() > 1}
<div class="flex items-center justify-end gap-2 pt-2">
<p class="mr-auto text-xs text-muted-foreground">
Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()} ({rows.length} строк)
</p>
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Назад
</Button>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Далее
</Button>
</div>
{/if}
@@ -0,0 +1,142 @@
import {
type RowData,
type TableOptions,
type TableOptionsResolved,
type TableState,
type Updater,
createTable,
} from "@tanstack/table-core";
/**
* Creates a reactive TanStack table object for Svelte.
* @param options Table options to create the table with.
* @returns A reactive table object.
* @example
* ```svelte
* <script>
* const table = createSvelteTable({ ... })
* </script>
*
* <table>
* <thead>
* {#each table.getHeaderGroups() as headerGroup}
* <tr>
* {#each headerGroup.headers as header}
* <th colspan={header.colSpan}>
* <FlexRender content={header.column.columnDef.header} context={header.getContext()} />
* </th>
* {/each}
* </tr>
* {/each}
* </thead>
* <!-- ... -->
* </table>
* ```
*/
export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) {
const resolvedOptions: TableOptionsResolved<TData> = mergeObjects(
{
state: {},
onStateChange() {},
renderFallbackValue: null,
mergeOptions: (
defaultOptions: TableOptions<TData>,
options: Partial<TableOptions<TData>>
) => {
return mergeObjects(defaultOptions, options);
},
},
options
);
const table = createTable(resolvedOptions);
let state = $state<TableState>(table.initialState);
function updateOptions() {
table.setOptions(() => {
return mergeObjects(resolvedOptions, options, {
state: mergeObjects(state, options.state || {}),
onStateChange: (updater: Updater<TableState>) => {
if (updater instanceof Function) state = updater(state);
else state = mergeObjects(state, updater);
options.onStateChange?.(updater);
},
});
});
}
updateOptions();
$effect.pre(() => {
updateOptions();
});
return table;
}
type MaybeThunk<T extends object> = T | (() => T | null | undefined);
type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R]
? H & Intersection<R>
: unknown) & {};
/**
* Lazily merges several objects (or thunks) while preserving
* getter semantics from every source.
*
* Proxy-based to avoid known WebKit recursion issue.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeObjects<Sources extends readonly MaybeThunk<any>[]>(
...sources: Sources
): Intersection<{ [K in keyof Sources]: Sources[K] }> {
const resolve = <T extends object>(src: MaybeThunk<T>): T | undefined =>
typeof src === "function" ? (src() ?? undefined) : src;
const findSourceWithKey = (key: PropertyKey) => {
for (let i = sources.length - 1; i >= 0; i--) {
const obj = resolve(sources[i]);
if (obj && key in obj) return obj;
}
return undefined;
};
return new Proxy(Object.create(null), {
get(_, key) {
const src = findSourceWithKey(key);
return src?.[key as never];
},
has(_, key) {
return !!findSourceWithKey(key);
},
ownKeys(): (string | symbol)[] {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const all = new Set<string | symbol>();
for (const s of sources) {
const obj = resolve(s);
if (obj) {
for (const k of Reflect.ownKeys(obj) as (string | symbol)[]) {
all.add(k);
}
}
}
return [...all];
},
getOwnPropertyDescriptor(_, key) {
const src = findSourceWithKey(key);
if (!src) return undefined;
return {
configurable: true,
enumerable: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: (src as any)[key],
writable: true,
};
},
}) as Intersection<{ [K in keyof Sources]: Sources[K] }>;
}
@@ -0,0 +1,40 @@
<script
lang="ts"
generics="TData, TValue, TContext extends HeaderContext<TData, TValue> | CellContext<TData, TValue>"
>
import type { CellContext, ColumnDefTemplate, HeaderContext } from "@tanstack/table-core";
import { RenderComponentConfig, RenderSnippetConfig } from "./render-helpers.js";
import type { Attachment } from "svelte/attachments";
type Props = {
/** The cell or header field of the current cell's column definition. */
content?: TContext extends HeaderContext<TData, TValue>
? ColumnDefTemplate<HeaderContext<TData, TValue>>
: TContext extends CellContext<TData, TValue>
? ColumnDefTemplate<CellContext<TData, TValue>>
: never;
/** The result of the `getContext()` function of the header or cell */
context: TContext;
/** Used to pass attachments that can't be gotten through context */
attach?: Attachment;
};
let { content, context, attach }: Props = $props();
</script>
{#if typeof content === "string"}
{content}
{:else if content instanceof Function}
<!-- It's unlikely that a CellContext will be passed to a Header -->
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
{@const result = content(context as any)}
{#if result instanceof RenderComponentConfig}
{@const { component: Component, props } = result}
<Component {...props} {attach} />
{:else if result instanceof RenderSnippetConfig}
{@const { snippet, params } = result}
{@render snippet({ ...params, attach })}
{:else}
{result}
{/if}
{/if}
@@ -0,0 +1,3 @@
export { default as FlexRender } from "./flex-render.svelte";
export { renderComponent, renderSnippet } from "./render-helpers.js";
export { createSvelteTable } from "./data-table.svelte.js";
@@ -0,0 +1,111 @@
import type { Component, ComponentProps, Snippet } from "svelte";
/**
* A helper class to make it easy to identify Svelte components in
* `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderComponentConfig}
* {@const { component: Component, props } = result}
* <Component {...props} />
* {/if}
* ```
*/
export class RenderComponentConfig<TComponent extends Component> {
component: TComponent;
props: ComponentProps<TComponent> | Record<string, never>;
constructor(
component: TComponent,
props: ComponentProps<TComponent> | Record<string, never> = {}
) {
this.component = component;
this.props = props;
}
}
/**
* A helper class to make it easy to identify Svelte Snippets in `columnDef.cell` and `columnDef.header` properties.
*
* > NOTE: This class should only be used internally by the adapter. If you're
* reading this and you don't know what this is for, you probably don't need it.
*
* @example
* ```svelte
* {@const result = content(context as any)}
* {#if result instanceof RenderSnippetConfig}
* {@const { snippet, params } = result}
* {@render snippet(params)}
* {/if}
* ```
*/
export class RenderSnippetConfig<TProps> {
snippet: Snippet<[TProps]>;
params: TProps;
constructor(snippet: Snippet<[TProps]>, params: TProps) {
this.snippet = snippet;
this.params = params;
}
}
/**
* A helper function to help create cells from Svelte components through ColumnDef's `cell` and `header` properties.
*
* This is only to be used with Svelte Components - use `renderSnippet` for Svelte Snippets.
*
* @param component A Svelte component
* @param props The props to pass to `component`
* @returns A `RenderComponentConfig` object that helps svelte-table know how to render the header/cell component.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* header: header => renderComponent(SortHeader, { label: 'Name', header }),
* }),
* columnHelper.accessor('state', {
* header: header => renderComponent(SortHeader, { label: 'State', header }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderComponent<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Component<any>,
Props extends ComponentProps<T>,
>(component: T, props: Props = {} as Props) {
return new RenderComponentConfig(component, props);
}
/**
* A helper function to help create cells from Svelte Snippets through ColumnDef's `cell` and `header` properties.
*
* The snippet must only take one parameter.
*
* This is only to be used with Snippets - use `renderComponent` for Svelte Components.
*
* @param snippet
* @param params
* @returns - A `RenderSnippetConfig` object that helps svelte-table know how to render the header/cell snippet.
* @example
* ```ts
* // +page.svelte
* const defaultColumns = [
* columnHelper.accessor('name', {
* cell: cell => renderSnippet(nameSnippet, { name: cell.row.name }),
* }),
* columnHelper.accessor('state', {
* cell: cell => renderSnippet(stateSnippet, { state: cell.row.state }),
* }),
* ]
* ```
* @see {@link https://tanstack.com/table/latest/docs/guide/column-defs}
*/
export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps = {} as TProps) {
return new RenderSnippetConfig(snippet, params);
}
+39
View File
@@ -0,0 +1,39 @@
import type { JobRow } from '$lib/api/types.js';
function metaString(meta: Record<string, unknown> | undefined, key: string): string | null {
if (!meta || typeof meta !== 'object') return null;
const v = meta[key];
return typeof v === 'string' && v.trim().length > 0 ? v : null;
}
/** Заголовок задачи для UI (русский), без технического `kind`. */
export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap<string, string>): string {
switch (job.kind) {
case 'module_refresh': {
const mid = metaString(job.meta as Record<string, unknown> | undefined, 'module_id');
const name = mid ? moduleNameById?.get(mid) : undefined;
if (name) return `Обновление модуля «${name}»`;
return 'Обновление модуля';
}
case 'deploy_apply':
return 'Применение конфигурации';
case 'revision_rollback':
return 'Откат ревизии';
case 'bird_reload':
return 'Перезагрузка BIRD';
default:
return job.kind;
}
}
/** Вторая строка под заголовком: `module_id`, если имя модуля ещё не известно. */
export function jobKindSubtitle(
job: JobRow,
moduleNameById?: ReadonlyMap<string, string>
): string | null {
if (job.kind !== 'module_refresh') return null;
const mid = metaString(job.meta as Record<string, unknown> | undefined, 'module_id');
if (!mid) return null;
if (moduleNameById?.has(mid)) return null;
return mid;
}
+126 -47
View File
@@ -18,8 +18,10 @@
RevisionPrefix,
RevisionDiff,
JobRow,
JobsResponse
JobsResponse,
ModulesResponse
} from '$lib/api/types.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
@@ -45,7 +47,11 @@
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte';
import type { JobDetailedReport, JobLogEntry, ReportRow } from '$lib/components/operations/types.js';
import type {
JobDetailedReport,
JobLogEntry,
ReportRow
} from '$lib/components/operations/types.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import {
dialogBodyDocument,
@@ -98,6 +104,7 @@
// Jobs
let jobs = $state<JobRow[]>([]);
let jobsLoading = $state(false);
let moduleNameById = $state(new Map<string, string>());
let cancelTarget = $state<JobRow | null>(null);
let cancelling = $state(false);
let jobDetailDialog = $state(false);
@@ -169,10 +176,24 @@
}
}
async function loadModules() {
try {
const r = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
const m = new Map<string, string>();
for (const mod of r.items ?? []) {
m.set(mod.id, mod.name);
}
moduleNameById = m;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
}
onMount(() => {
loadRevisions();
loadJobs();
loadBirdStatus();
loadModules();
});
async function openPreview(rev: RevisionRow) {
@@ -200,7 +221,10 @@
}
async function loadDiff() {
if (!diffRevA || !diffRevB) { toast.error('Выберите две ревизии'); return; }
if (!diffRevA || !diffRevB) {
toast.error('Выберите две ревизии');
return;
}
diffLoading = true;
try {
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
@@ -247,9 +271,7 @@
toast.success(extra ? `Apply успешно. ${extra}` : 'Apply успешно завершён');
} else {
toast.error(
job.error
? `${job.status}: ${job.error}`
: `Задача завершилась со статусом ${job.status}`
job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}`
);
}
await loadJobs();
@@ -276,9 +298,7 @@
toast.success(extra ? `Reload успешно. ${extra}` : 'birdc configure выполнен');
} else {
toast.error(
job.error
? `${job.status}: ${job.error}`
: `Задача завершилась со статусом ${job.status}`
job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}`
);
}
await loadJobs();
@@ -555,7 +575,9 @@
return new Date(d).toLocaleString('ru');
}
function birdHealthyBadgeVariant(h: boolean | null | undefined): 'default' | 'secondary' | 'outline' | 'destructive' {
function birdHealthyBadgeVariant(
h: boolean | null | undefined
): 'default' | 'secondary' | 'outline' | 'destructive' {
if (h === true) return 'default';
if (h === false) return 'destructive';
return 'outline';
@@ -571,7 +593,9 @@
<div class="space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Операции</h1>
<p class="text-muted-foreground mt-1 text-sm">Деплой конфигурации, управление ревизиями и задачами.</p>
<p class="mt-1 text-sm text-muted-foreground">
Деплой конфигурации, управление ревизиями и задачами.
</p>
</div>
<OperationsQuickActions
@@ -590,9 +614,9 @@
<Tabs value="revisions">
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
<TabsList class="inline-flex min-w-max">
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
<TabsTrigger value="diff">Diff</TabsTrigger>
<TabsTrigger value="jobs">Задачи</TabsTrigger>
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
<TabsTrigger value="diff">Diff</TabsTrigger>
<TabsTrigger value="jobs">Задачи</TabsTrigger>
</TabsList>
</div>
@@ -625,6 +649,7 @@
<OperationsJobsTab
{jobs}
{jobsLoading}
{moduleNameById}
{expandedJobIds}
{jobDetailsById}
{jobDetailsLoading}
@@ -649,11 +674,15 @@
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Запустить Apply на всех спикерах?</AlertDialogTitle>
<AlertDialogDescription>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription>
<AlertDialogDescription
>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doApply} disabled={applying}>{applying ? 'Apply…' : 'Применить'}</AlertDialogAction>
<AlertDialogAction onclick={doApply} disabled={applying}
>{applying ? 'Apply…' : 'Применить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -663,39 +692,64 @@
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reload BIRD?</AlertDialogTitle>
<AlertDialogDescription>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription>
<AlertDialogDescription
>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={doBirdReload} disabled={reloading}>{reloading ? 'Reload…' : 'Reload'}</AlertDialogAction>
<AlertDialogAction onclick={doBirdReload} disabled={reloading}
>{reloading ? 'Reload…' : 'Reload'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Rollback confirm -->
<AlertDialog open={!!rollbackTarget} onOpenChange={(v) => { if (!v) rollbackTarget = null; }}>
<AlertDialog
open={!!rollbackTarget}
onOpenChange={(v) => {
if (!v) rollbackTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…?</AlertDialogTitle>
<AlertDialogDescription>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription>
<AlertDialogDescription
>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (rollbackTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={rollback} disabled={rollingBack}>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction>
<AlertDialogAction onclick={rollback} disabled={rollingBack}
>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Cancel job confirm -->
<AlertDialog open={!!cancelTarget} onOpenChange={(v) => { if (!v) cancelTarget = null; }}>
<AlertDialog
open={!!cancelTarget}
onOpenChange={(v) => {
if (!v) cancelTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Отменить задачу?</AlertDialogTitle>
<AlertDialogDescription>Задача: {cancelTarget?.kind} ({cancelTarget?.job_id?.slice(0, 8)}…)</AlertDialogDescription>
<AlertDialogDescription>
Задача: {cancelTarget ? jobKindTitle(cancelTarget, moduleNameById) : ''} ({cancelTarget?.job_id?.slice(
0,
8
)}…)
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (cancelTarget = null)}>Нет</AlertDialogCancel>
<AlertDialogAction onclick={cancelJob} disabled={cancelling}>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction>
<AlertDialogAction onclick={cancelJob} disabled={cancelling}
>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction
>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -710,7 +764,7 @@
</DialogDescription>
</DialogHeader>
{#if previewLoading}
<div class="text-muted-foreground px-6 py-10 text-center text-sm">Загрузка…</div>
<div class="px-6 py-10 text-center text-sm text-muted-foreground">Загрузка…</div>
{:else}
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
<Tabs bind:value={previewSubTab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
@@ -718,16 +772,21 @@
<TabsTrigger value="bird">Конфиг BIRD</TabsTrigger>
<TabsTrigger value="prefixes">Префиксы</TabsTrigger>
</TabsList>
<TabsContent value="bird" class="mt-0 flex min-h-0 min-w-0 flex-1 flex-col gap-2 outline-none">
<TabsContent
value="bird"
class="mt-0 flex min-h-0 min-w-0 flex-1 flex-col gap-2 outline-none"
>
{@const frags = asPreviewFragments(previewData)}
{#if Object.keys(frags).length === 0}
<p class="text-muted-foreground text-sm">Нет фрагментов превью (старая ревизия или пустой render).</p>
<p class="text-sm text-muted-foreground">
Нет фрагментов превью (старая ревизия или пустой render).
</p>
{:else}
<div class="flex shrink-0 flex-wrap items-center gap-2">
<label for="bird-frag" class="text-muted-foreground text-sm">Файл</label>
<label for="bird-frag" class="text-sm text-muted-foreground">Файл</label>
<select
id="bird-frag"
class="border-input bg-background ring-offset-background focus-visible:ring-ring max-w-xl min-w-0 flex-1 rounded-md border px-2 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
class="max-w-xl min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
bind:value={birdPreviewPath}
>
{#each Object.keys(frags).sort() as path (path)}
@@ -735,9 +794,10 @@
{/each}
</select>
</div>
<p class="text-muted-foreground shrink-0 text-xs">
Совет: откройте <code class="bg-muted rounded px-1">_bird_full_expanded.conf</code> — один текст с
<code class="bg-muted rounded px-1">bird.conf</code> и содержимым всех include.
<p class="shrink-0 text-xs text-muted-foreground">
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
— один текст с
<code class="rounded bg-muted px-1">bird.conf</code> и содержимым всех include.
</p>
<ScrollPreBlock
variant="wrap"
@@ -746,13 +806,16 @@
/>
{/if}
</TabsContent>
<TabsContent value="prefixes" class="mt-0 flex min-h-0 min-w-0 flex-1 flex-col gap-2 outline-none">
<p class="text-sm shrink-0">
<TabsContent
value="prefixes"
class="mt-0 flex min-h-0 min-w-0 flex-1 flex-col gap-2 outline-none"
>
<p class="shrink-0 text-sm">
<span class="font-medium">Префиксов:</span>
{prefixesData.length}
</p>
<div
class="border-border bg-muted/40 flex min-h-[10rem] min-w-0 flex-1 flex-col overflow-auto rounded-lg border p-3 [scrollbar-gutter:stable] overscroll-contain"
class="flex min-h-[10rem] min-w-0 flex-1 flex-col overflow-auto overscroll-contain rounded-lg border border-border bg-muted/40 p-3 [scrollbar-gutter:stable]"
>
{#each prefixesData as pfx, idx (`${idx}-${pfx}`)}
<p class="font-mono text-xs">{pfx}</p>
@@ -773,11 +836,11 @@
<DialogHeader class={dialogHeaderDocument}>
<DialogTitle>Вывод birdc (протоколы)</DialogTitle>
<DialogDescription>
Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. Таблица сохраняет выравнивание
колонок (горизонтальная прокрутка).
Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. Таблица сохраняет
выравнивание колонок (горизонтальная прокрутка).
</DialogDescription>
</DialogHeader>
<div class="px-6 pb-6 pt-0">
<div class="px-6 pt-0 pb-6">
<ScrollPreBlock
variant="preserve"
text={birdStatus?.protocols_excerpt ?? ''}
@@ -791,24 +854,40 @@
<Dialog bind:open={jobDetailDialog}>
<DialogContent class={dialogContentPanel}>
<DialogHeader class={dialogHeaderPanel}>
<DialogTitle>Задача: {jobDetail?.kind}</DialogTitle>
<DialogTitle>Задача: {jobDetail ? jobKindTitle(jobDetail, moduleNameById) : ''}</DialogTitle>
</DialogHeader>
{#if jobDetail}
<div class={cn(dialogBodyPanel, 'max-h-[80vh] overflow-y-auto')}>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<span class="text-muted-foreground">ID</span><span class="font-mono break-all">{jobDetail.job_id}</span>
<span class="text-muted-foreground">Статус</span><span><Badge variant={jobStatusVariant(jobDetail.status)}>{jobDetail.status}</Badge></span>
<span class="text-muted-foreground">Создана</span><span>{formatDate(jobDetail.created_at)}</span>
<span class="text-muted-foreground">Начата</span><span>{formatDate(jobDetail.started_at)}</span>
<span class="text-muted-foreground">Завершена</span><span>{formatDate(jobDetail.finished_at)}</span>
<span class="text-muted-foreground">ID</span><span class="font-mono break-all"
>{jobDetail.job_id}</span
>
<span class="text-muted-foreground">Статус</span><span
><Badge variant={jobStatusVariant(jobDetail.status)}>{jobDetail.status}</Badge></span
>
<span class="text-muted-foreground">Создана</span><span
>{formatDate(jobDetail.created_at)}</span
>
<span class="text-muted-foreground">Начата</span><span
>{formatDate(jobDetail.started_at)}</span
>
<span class="text-muted-foreground">Завершена</span><span
>{formatDate(jobDetail.finished_at)}</span
>
{#if jobDetail.error}
<span class="text-muted-foreground">Ошибка</span><span class="text-destructive break-words">{jobDetail.error}</span>
<span class="text-muted-foreground">Ошибка</span><span
class="break-words text-destructive">{jobDetail.error}</span
>
{/if}
</div>
{#if jobDetail.meta && Object.keys(jobDetail.meta).length > 0}
<div class="mt-4 space-y-2">
<p class="text-muted-foreground text-sm font-medium">Meta</p>
<ScrollPreBlock variant="wrap" text={JSON.stringify(jobDetail.meta, null, 2)} class="max-h-52 w-full" />
<p class="text-sm font-medium text-muted-foreground">Meta</p>
<ScrollPreBlock
variant="wrap"
text={JSON.stringify(jobDetail.meta, null, 2)}
class="max-h-52 w-full"
/>
</div>
{/if}
</div>