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
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:
Generated
+14
@@ -8,6 +8,7 @@
|
|||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"bits-ui": "^2.17.2",
|
"bits-ui": "^2.17.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"svelte-sonner": "^1.1.0",
|
"svelte-sonner": "^1.1.0",
|
||||||
@@ -1258,6 +1259,19 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"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": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -14,13 +14,13 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.15.0",
|
|
||||||
"@internationalized/date": "^3.12.0",
|
"@internationalized/date": "^3.12.0",
|
||||||
"@lucide/svelte": "^1.7.0",
|
"@lucide/svelte": "^1.7.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.50.2",
|
"@sveltejs/kit": "^2.50.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^22.15.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"bits-ui": "^2.17.2",
|
"bits-ui": "^2.17.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"svelte-sonner": "^1.1.0",
|
"svelte-sonner": "^1.1.0",
|
||||||
|
|||||||
@@ -4,17 +4,27 @@
|
|||||||
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/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 {
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/components/ui/card/index.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||||
import { cn } from '$lib/utils.js';
|
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 = {
|
type Props = {
|
||||||
jobs: JobRow[];
|
jobs: JobRow[];
|
||||||
jobsLoading: boolean;
|
jobsLoading: boolean;
|
||||||
|
moduleNameById: ReadonlyMap<string, string>;
|
||||||
expandedJobIds: SvelteSet<string>;
|
expandedJobIds: SvelteSet<string>;
|
||||||
jobDetailsById: SvelteMap<string, JobRow>;
|
jobDetailsById: SvelteMap<string, JobRow>;
|
||||||
jobDetailsLoading: SvelteSet<string>;
|
jobDetailsLoading: SvelteSet<string>;
|
||||||
@@ -34,6 +44,7 @@
|
|||||||
let {
|
let {
|
||||||
jobs,
|
jobs,
|
||||||
jobsLoading,
|
jobsLoading,
|
||||||
|
moduleNameById,
|
||||||
expandedJobIds,
|
expandedJobIds,
|
||||||
jobDetailsById,
|
jobDetailsById,
|
||||||
jobDetailsLoading,
|
jobDetailsLoading,
|
||||||
@@ -49,6 +60,12 @@
|
|||||||
jobStatusVariant,
|
jobStatusVariant,
|
||||||
formatDate
|
formatDate
|
||||||
}: Props = $props();
|
}: 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>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -57,34 +74,56 @@
|
|||||||
<CardTitle class="text-base">Задачи</CardTitle>
|
<CardTitle class="text-base">Задачи</CardTitle>
|
||||||
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
|
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
|
||||||
</div>
|
</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' : ''} />
|
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</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)}
|
{#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-col gap-3 p-3 sm:p-4">
|
||||||
<div class="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
class="text-muted-foreground hover:text-foreground mt-0.5 shrink-0"
|
class="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={isJobExpanded(job.job_id) ? 'Свернуть детали задачи' : 'Развернуть детали задачи'}
|
aria-label={isJobExpanded(job.job_id)
|
||||||
|
? 'Свернуть детали задачи'
|
||||||
|
: 'Развернуть детали задачи'}
|
||||||
aria-expanded={isJobExpanded(job.job_id)}
|
aria-expanded={isJobExpanded(job.job_id)}
|
||||||
onclick={() => void onToggleJobExpanded(job)}
|
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>
|
</Button>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-medium break-words">{job.kind}</p>
|
<p class="font-medium break-words">{jobKindTitle(job, moduleNameById)}</p>
|
||||||
<p class="text-muted-foreground font-mono text-xs break-all">{job.job_id}</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>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<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" />
|
<Eye class="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{#if job.status === 'running' || job.status === 'queued'}
|
{#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="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">
|
<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>
|
<Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
<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>
|
<p>{formatDate(job.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
<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>
|
<p>{formatDate(job.started_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
<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>
|
<p>{formatDate(job.finished_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,16 +167,16 @@
|
|||||||
{@const jobReport = jobReportsById.get(job.job_id)}
|
{@const jobReport = jobReportsById.get(job.job_id)}
|
||||||
{@const logEntries = getJobLogEntries(detailedJob)}
|
{@const logEntries = getJobLogEntries(detailedJob)}
|
||||||
{@const logTotal = getJobLogTotal(detailedJob, logEntries)}
|
{@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">
|
<div class="space-y-3 pr-1">
|
||||||
{#if isDetailsLoading}
|
{#if isDetailsLoading}
|
||||||
<p class="text-muted-foreground text-xs">Догружаем свежие детали задачи…</p>
|
<p class="text-xs text-muted-foreground">Догружаем свежие детали задачи…</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if detailedJob.error}
|
{#if detailedJob.error}
|
||||||
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
<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="mb-1 text-xs text-muted-foreground">error</p>
|
||||||
<p class="text-destructive break-words text-sm">{detailedJob.error}</p>
|
<p class="text-sm break-words text-destructive">{detailedJob.error}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -145,54 +184,94 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p class="text-sm font-medium">Журнал обработки</p>
|
<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} префиксов
|
{logEntries.length} записей, всего {logTotal} префиксов
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="space-y-2">
|
||||||
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
|
{#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">
|
<div
|
||||||
<p class="text-sm leading-relaxed font-medium break-words">{entry.message}</p>
|
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="grid gap-2 md:grid-cols-2">
|
||||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
||||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">source</span>
|
<span
|
||||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.source}</p>
|
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>
|
||||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
||||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">kind</span>
|
<span
|
||||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.kind}</p>
|
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>
|
||||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
||||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">community</span>
|
<span
|
||||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.community}</p>
|
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>
|
||||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
||||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">prefixes</span>
|
<span
|
||||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs">{entry.prefix_count}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
{#if entry.sample && entry.sample.length > 0}
|
{#if entry.sample && entry.sample.length > 0}
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<p class="text-muted-foreground text-[11px] uppercase tracking-wide">sample</p>
|
<p class="text-[11px] tracking-wide text-muted-foreground uppercase">
|
||||||
<ScrollArea class="max-h-28 rounded-md border bg-muted/35 p-2.5">
|
sample
|
||||||
|
</p>
|
||||||
|
<div class="rounded-md border bg-muted/35 p-2.5">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isReportLoading}
|
{#if isReportLoading}
|
||||||
<p class="text-muted-foreground text-xs">Собираем подробный отчёт по источникам и агрегации…</p>
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Собираем подробный отчёт по источникам и агрегации…
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if jobReport}
|
{#if jobReport}
|
||||||
@@ -205,31 +284,35 @@
|
|||||||
<Badge variant="secondary">{jobReport.module.name}</Badge>
|
<Badge variant="secondary">{jobReport.module.name}</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
{#if jobReport.revisionId}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid min-w-0 gap-2 md:grid-cols-2 xl:grid-cols-4">
|
<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">
|
<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>
|
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
|
<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>
|
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
|
<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>
|
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
|
<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-[11px] text-muted-foreground uppercase">CDN/IP Range</p>
|
||||||
<p class="text-sm font-semibold">{jobReport.cdn.length}/{jobReport.ipRanges.length}</p>
|
<p class="text-sm font-semibold">
|
||||||
|
{jobReport.cdn.length}/{jobReport.ipRanges.length}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if jobReport.aggregationByKind.length > 0}
|
{#if jobReport.aggregationByKind.length > 0}
|
||||||
<div class="space-y-1">
|
<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">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
|
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
|
||||||
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge>
|
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge>
|
||||||
@@ -239,77 +322,65 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-3 xl:grid-cols-2">
|
<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">
|
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
||||||
<p class="text-sm font-medium">Домены: какой домен какие IP/префиксы вернул</p>
|
<p class="text-sm font-medium">
|
||||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
Домены: какой домен какие IP/префиксы вернул
|
||||||
{#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
|
</p>
|
||||||
<div class="mb-2 rounded border bg-background p-2">
|
<JobReportTableBlock
|
||||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
rows={jobReport.domains as RowData[]}
|
||||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
|
columns={reportCols}
|
||||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
emptyLabel="Нет данных по доменам"
|
||||||
</div>
|
/>
|
||||||
{:else}
|
|
||||||
<p class="text-muted-foreground text-xs">Нет данных по доменам</p>
|
|
||||||
{/each}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</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>
|
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
|
||||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
<JobReportTableBlock
|
||||||
{#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
|
rows={jobReport.asn as RowData[]}
|
||||||
<div class="mb-2 flex items-center justify-between rounded border bg-background p-2">
|
columns={asnCols}
|
||||||
<span class="font-mono text-xs">AS{row.asn}</span>
|
emptyLabel="Нет данных по ASN"
|
||||||
<Badge variant="outline">{row.prefixCount}</Badge>
|
/>
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-muted-foreground text-xs">Нет данных по ASN</p>
|
|
||||||
{/each}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</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">CDN: из каждой ссылки полученные IP/префиксы</p>
|
<p class="text-sm font-medium">
|
||||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
CDN: из каждой ссылки полученные IP/префиксы
|
||||||
{#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
|
</p>
|
||||||
<div class="mb-2 rounded border bg-background p-2">
|
<JobReportTableBlock
|
||||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
rows={jobReport.cdn as RowData[]}
|
||||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
|
columns={reportCols}
|
||||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
emptyLabel="Нет данных по CDN"
|
||||||
</div>
|
/>
|
||||||
{:else}
|
|
||||||
<p class="text-muted-foreground text-xs">Нет данных по CDN</p>
|
|
||||||
{/each}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
</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>
|
<p class="text-sm font-medium">IP range: итог по статическим диапазонам</p>
|
||||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
<JobReportTableBlock
|
||||||
{#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
|
rows={jobReport.ipRanges as RowData[]}
|
||||||
<div class="mb-2 rounded border bg-background p-2">
|
columns={reportCols}
|
||||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
emptyLabel="Нет данных по IP range"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-muted-foreground text-xs">Meta (raw JSON)</p>
|
<p class="text-xs text-muted-foreground">Meta (raw JSON)</p>
|
||||||
<ScrollArea class="max-h-56 rounded-md border bg-muted/30 p-3">
|
<div class="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>
|
<pre
|
||||||
</ScrollArea>
|
class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify(
|
||||||
|
detailedJob.meta ?? {},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/each}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
RevisionPrefix,
|
RevisionPrefix,
|
||||||
RevisionDiff,
|
RevisionDiff,
|
||||||
JobRow,
|
JobRow,
|
||||||
JobsResponse
|
JobsResponse,
|
||||||
|
ModulesResponse
|
||||||
} from '$lib/api/types.js';
|
} from '$lib/api/types.js';
|
||||||
|
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/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 OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
|
||||||
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
||||||
import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.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 ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
import {
|
import {
|
||||||
dialogBodyDocument,
|
dialogBodyDocument,
|
||||||
@@ -98,6 +104,7 @@
|
|||||||
// Jobs
|
// Jobs
|
||||||
let jobs = $state<JobRow[]>([]);
|
let jobs = $state<JobRow[]>([]);
|
||||||
let jobsLoading = $state(false);
|
let jobsLoading = $state(false);
|
||||||
|
let moduleNameById = $state(new Map<string, string>());
|
||||||
let cancelTarget = $state<JobRow | null>(null);
|
let cancelTarget = $state<JobRow | null>(null);
|
||||||
let cancelling = $state(false);
|
let cancelling = $state(false);
|
||||||
let jobDetailDialog = $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(() => {
|
onMount(() => {
|
||||||
loadRevisions();
|
loadRevisions();
|
||||||
loadJobs();
|
loadJobs();
|
||||||
loadBirdStatus();
|
loadBirdStatus();
|
||||||
|
loadModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function openPreview(rev: RevisionRow) {
|
async function openPreview(rev: RevisionRow) {
|
||||||
@@ -200,7 +221,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDiff() {
|
async function loadDiff() {
|
||||||
if (!diffRevA || !diffRevB) { toast.error('Выберите две ревизии'); return; }
|
if (!diffRevA || !diffRevB) {
|
||||||
|
toast.error('Выберите две ревизии');
|
||||||
|
return;
|
||||||
|
}
|
||||||
diffLoading = true;
|
diffLoading = true;
|
||||||
try {
|
try {
|
||||||
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
|
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
|
||||||
@@ -247,9 +271,7 @@
|
|||||||
toast.success(extra ? `Apply успешно. ${extra}` : 'Apply успешно завершён');
|
toast.success(extra ? `Apply успешно. ${extra}` : 'Apply успешно завершён');
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
job.error
|
job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}`
|
||||||
? `${job.status}: ${job.error}`
|
|
||||||
: `Задача завершилась со статусом ${job.status}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
@@ -276,9 +298,7 @@
|
|||||||
toast.success(extra ? `Reload успешно. ${extra}` : 'birdc configure выполнен');
|
toast.success(extra ? `Reload успешно. ${extra}` : 'birdc configure выполнен');
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
job.error
|
job.error ? `${job.status}: ${job.error}` : `Задача завершилась со статусом ${job.status}`
|
||||||
? `${job.status}: ${job.error}`
|
|
||||||
: `Задача завершилась со статусом ${job.status}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
@@ -555,7 +575,9 @@
|
|||||||
return new Date(d).toLocaleString('ru');
|
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 === true) return 'default';
|
||||||
if (h === false) return 'destructive';
|
if (h === false) return 'destructive';
|
||||||
return 'outline';
|
return 'outline';
|
||||||
@@ -571,7 +593,9 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Операции</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<OperationsQuickActions
|
<OperationsQuickActions
|
||||||
@@ -590,9 +614,9 @@
|
|||||||
<Tabs value="revisions">
|
<Tabs value="revisions">
|
||||||
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
||||||
<TabsList class="inline-flex min-w-max">
|
<TabsList class="inline-flex min-w-max">
|
||||||
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
|
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
|
||||||
<TabsTrigger value="diff">Diff</TabsTrigger>
|
<TabsTrigger value="diff">Diff</TabsTrigger>
|
||||||
<TabsTrigger value="jobs">Задачи</TabsTrigger>
|
<TabsTrigger value="jobs">Задачи</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -625,6 +649,7 @@
|
|||||||
<OperationsJobsTab
|
<OperationsJobsTab
|
||||||
{jobs}
|
{jobs}
|
||||||
{jobsLoading}
|
{jobsLoading}
|
||||||
|
{moduleNameById}
|
||||||
{expandedJobIds}
|
{expandedJobIds}
|
||||||
{jobDetailsById}
|
{jobDetailsById}
|
||||||
{jobDetailsLoading}
|
{jobDetailsLoading}
|
||||||
@@ -649,11 +674,15 @@
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Запустить Apply на всех спикерах?</AlertDialogTitle>
|
<AlertDialogTitle>Запустить Apply на всех спикерах?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription>
|
<AlertDialogDescription
|
||||||
|
>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription
|
||||||
|
>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
<AlertDialogAction onclick={doApply} disabled={applying}>{applying ? 'Apply…' : 'Применить'}</AlertDialogAction>
|
<AlertDialogAction onclick={doApply} disabled={applying}
|
||||||
|
>{applying ? 'Apply…' : 'Применить'}</AlertDialogAction
|
||||||
|
>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
@@ -663,39 +692,64 @@
|
|||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Reload BIRD?</AlertDialogTitle>
|
<AlertDialogTitle>Reload BIRD?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription>
|
<AlertDialogDescription
|
||||||
|
>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription
|
||||||
|
>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||||
<AlertDialogAction onclick={doBirdReload} disabled={reloading}>{reloading ? 'Reload…' : 'Reload'}</AlertDialogAction>
|
<AlertDialogAction onclick={doBirdReload} disabled={reloading}
|
||||||
|
>{reloading ? 'Reload…' : 'Reload'}</AlertDialogAction
|
||||||
|
>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- Rollback confirm -->
|
<!-- Rollback confirm -->
|
||||||
<AlertDialog open={!!rollbackTarget} onOpenChange={(v) => { if (!v) rollbackTarget = null; }}>
|
<AlertDialog
|
||||||
|
open={!!rollbackTarget}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) rollbackTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…?</AlertDialogTitle>
|
<AlertDialogTitle>Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription>
|
<AlertDialogDescription
|
||||||
|
>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription
|
||||||
|
>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onclick={() => (rollbackTarget = null)}>Отмена</AlertDialogCancel>
|
<AlertDialogCancel onclick={() => (rollbackTarget = null)}>Отмена</AlertDialogCancel>
|
||||||
<AlertDialogAction onclick={rollback} disabled={rollingBack}>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction>
|
<AlertDialogAction onclick={rollback} disabled={rollingBack}
|
||||||
|
>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction
|
||||||
|
>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<!-- Cancel job confirm -->
|
<!-- Cancel job confirm -->
|
||||||
<AlertDialog open={!!cancelTarget} onOpenChange={(v) => { if (!v) cancelTarget = null; }}>
|
<AlertDialog
|
||||||
|
open={!!cancelTarget}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) cancelTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Отменить задачу?</AlertDialogTitle>
|
<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>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onclick={() => (cancelTarget = null)}>Нет</AlertDialogCancel>
|
<AlertDialogCancel onclick={() => (cancelTarget = null)}>Нет</AlertDialogCancel>
|
||||||
<AlertDialogAction onclick={cancelJob} disabled={cancelling}>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction>
|
<AlertDialogAction onclick={cancelJob} disabled={cancelling}
|
||||||
|
>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction
|
||||||
|
>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
@@ -710,7 +764,7 @@
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{#if previewLoading}
|
{#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}
|
{:else}
|
||||||
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
|
<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">
|
<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="bird">Конфиг BIRD</TabsTrigger>
|
||||||
<TabsTrigger value="prefixes">Префиксы</TabsTrigger>
|
<TabsTrigger value="prefixes">Префиксы</TabsTrigger>
|
||||||
</TabsList>
|
</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)}
|
{@const frags = asPreviewFragments(previewData)}
|
||||||
{#if Object.keys(frags).length === 0}
|
{#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}
|
{:else}
|
||||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
<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
|
<select
|
||||||
id="bird-frag"
|
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}
|
bind:value={birdPreviewPath}
|
||||||
>
|
>
|
||||||
{#each Object.keys(frags).sort() as path (path)}
|
{#each Object.keys(frags).sort() as path (path)}
|
||||||
@@ -735,9 +794,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted-foreground shrink-0 text-xs">
|
<p class="shrink-0 text-xs text-muted-foreground">
|
||||||
Совет: откройте <code class="bg-muted rounded px-1">_bird_full_expanded.conf</code> — один текст с
|
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
|
||||||
<code class="bg-muted rounded px-1">bird.conf</code> и содержимым всех include.
|
— один текст с
|
||||||
|
<code class="rounded bg-muted px-1">bird.conf</code> и содержимым всех include.
|
||||||
</p>
|
</p>
|
||||||
<ScrollPreBlock
|
<ScrollPreBlock
|
||||||
variant="wrap"
|
variant="wrap"
|
||||||
@@ -746,13 +806,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="prefixes" class="mt-0 flex min-h-0 min-w-0 flex-1 flex-col gap-2 outline-none">
|
<TabsContent
|
||||||
<p class="text-sm shrink-0">
|
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>
|
<span class="font-medium">Префиксов:</span>
|
||||||
{prefixesData.length}
|
{prefixesData.length}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<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}`)}
|
{#each prefixesData as pfx, idx (`${idx}-${pfx}`)}
|
||||||
<p class="font-mono text-xs">{pfx}</p>
|
<p class="font-mono text-xs">{pfx}</p>
|
||||||
@@ -773,11 +836,11 @@
|
|||||||
<DialogHeader class={dialogHeaderDocument}>
|
<DialogHeader class={dialogHeaderDocument}>
|
||||||
<DialogTitle>Вывод birdc (протоколы)</DialogTitle>
|
<DialogTitle>Вывод birdc (протоколы)</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. Таблица сохраняет выравнивание
|
Фрагмент ответа на этом API-хосте; при длинном выводе обрезан на сервере. Таблица сохраняет
|
||||||
колонок (горизонтальная прокрутка).
|
выравнивание колонок (горизонтальная прокрутка).
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="px-6 pb-6 pt-0">
|
<div class="px-6 pt-0 pb-6">
|
||||||
<ScrollPreBlock
|
<ScrollPreBlock
|
||||||
variant="preserve"
|
variant="preserve"
|
||||||
text={birdStatus?.protocols_excerpt ?? ''}
|
text={birdStatus?.protocols_excerpt ?? ''}
|
||||||
@@ -791,24 +854,40 @@
|
|||||||
<Dialog bind:open={jobDetailDialog}>
|
<Dialog bind:open={jobDetailDialog}>
|
||||||
<DialogContent class={dialogContentPanel}>
|
<DialogContent class={dialogContentPanel}>
|
||||||
<DialogHeader class={dialogHeaderPanel}>
|
<DialogHeader class={dialogHeaderPanel}>
|
||||||
<DialogTitle>Задача: {jobDetail?.kind}</DialogTitle>
|
<DialogTitle>Задача: {jobDetail ? jobKindTitle(jobDetail, moduleNameById) : ''}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{#if jobDetail}
|
{#if jobDetail}
|
||||||
<div class={cn(dialogBodyPanel, 'max-h-[80vh] overflow-y-auto')}>
|
<div class={cn(dialogBodyPanel, 'max-h-[80vh] overflow-y-auto')}>
|
||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
<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">ID</span><span class="font-mono break-all"
|
||||||
<span class="text-muted-foreground">Статус</span><span><Badge variant={jobStatusVariant(jobDetail.status)}>{jobDetail.status}</Badge></span>
|
>{jobDetail.job_id}</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
|
||||||
<span class="text-muted-foreground">Завершена</span><span>{formatDate(jobDetail.finished_at)}</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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if jobDetail.meta && Object.keys(jobDetail.meta).length > 0}
|
{#if jobDetail.meta && Object.keys(jobDetail.meta).length > 0}
|
||||||
<div class="mt-4 space-y-2">
|
<div class="mt-4 space-y-2">
|
||||||
<p class="text-muted-foreground text-sm font-medium">Meta</p>
|
<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" />
|
<ScrollPreBlock
|
||||||
|
variant="wrap"
|
||||||
|
text={JSON.stringify(jobDetail.meta, null, 2)}
|
||||||
|
class="max-h-52 w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user