Compare commits
2
Commits
0ca5542f6b
...
b140dcbcf2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b140dcbcf2 | ||
|
|
b541794d84 |
No files matched your search
@@ -10,6 +10,10 @@ import (
|
||||
|
||||
var standardPairRE = regexp.MustCompile(`^\s*(\d{1,5}):(\d{1,5})\s*$`)
|
||||
|
||||
// badCommunityAddRE matches invalid BIRD filter calls like bgp_community.add(65000,1)
|
||||
// (first argument is an int). Valid form is bgp_community.add((65000,1)); — note the pair tuple.
|
||||
var badCommunityAddRE = regexp.MustCompile(`\b(?:bgp_community|bgp_large_community)\.add\(\s*\d`)
|
||||
|
||||
// RouteCommunityAttrs returns BIRD 2 lines (indented with 4 spaces) for use inside a static `route … { … }` block.
|
||||
// Empty string means no community attributes.
|
||||
func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) {
|
||||
@@ -20,26 +24,46 @@ func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) {
|
||||
}
|
||||
|
||||
if s, ok := parseLargeFromJSONLoose(raw); ok {
|
||||
if err := validateCommunityRouteBody(s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
_ = kind // reserved for future kinds (e.g. extended communities)
|
||||
|
||||
if m := standardPairRE.FindStringSubmatch(name); len(m) == 3 {
|
||||
return standardAddLine(m[1], m[2])
|
||||
s, err := standardAddLine(m[1], m[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, validateCommunityRouteBody(s)
|
||||
}
|
||||
|
||||
var obj map[string]any
|
||||
if json.Unmarshal([]byte(raw), &obj) == nil {
|
||||
if v, ok := obj["standard"].(string); ok {
|
||||
if m := standardPairRE.FindStringSubmatch(v); len(m) == 3 {
|
||||
return standardAddLine(m[1], m[2])
|
||||
s, err := standardAddLine(m[1], m[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, validateCommunityRouteBody(s)
|
||||
}
|
||||
}
|
||||
if arr, ok := obj["standard"].([]any); ok && len(arr) == 2 {
|
||||
a, aok := numberToUint32String(arr[0])
|
||||
b, bok := numberToUint32String(arr[1])
|
||||
if aok && bok {
|
||||
return standardAddLine(a, b)
|
||||
if arr, ok := obj["standard"].([]any); ok {
|
||||
if len(arr) == 1 {
|
||||
return "", fmt.Errorf(`birdfmt: value_json "standard" must be a pair [a,b] or "a:b" string, got single element`)
|
||||
}
|
||||
if len(arr) == 2 {
|
||||
a, aok := numberToUint32String(arr[0])
|
||||
b, bok := numberToUint32String(arr[1])
|
||||
if aok && bok {
|
||||
s, err := standardAddLine(a, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, validateCommunityRouteBody(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,13 +71,36 @@ func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) {
|
||||
var strVal string
|
||||
if json.Unmarshal([]byte(raw), &strVal) == nil {
|
||||
if m := standardPairRE.FindStringSubmatch(strVal); len(m) == 3 {
|
||||
return standardAddLine(m[1], m[2])
|
||||
s, err := standardAddLine(m[1], m[2])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s, validateCommunityRouteBody(s)
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// validateCommunityRouteBody rejects lines that would make BIRD 2 fail parse-check with
|
||||
// "Argument 1 of 'add' expected clist / quad / ip / pair, got int" (wrong add() shape).
|
||||
func validateCommunityRouteBody(body string) error {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if badCommunityAddRE.MatchString(line) {
|
||||
return fmt.Errorf("birdfmt: invalid community line (expected pair/triple tuple inside add(), e.g. bgp_community.add((65000,1));): %q", line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func standardAddLine(a, b string) (string, error) {
|
||||
ai, err1 := strconv.ParseUint(strings.TrimSpace(a), 10, 16)
|
||||
bi, err2 := strconv.ParseUint(strings.TrimSpace(b), 10, 16)
|
||||
@@ -111,6 +158,11 @@ func numberToUint32String(v any) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(uint64(x), 10), true
|
||||
case int64:
|
||||
if x < 0 || x > 4294967295 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(uint64(x), 10), true
|
||||
case string:
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,3 +21,10 @@ func TestRouteCommunityAttrs_LargeJSON(t *testing.T) {
|
||||
t.Fatalf("got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteCommunityAttrs_StandardArrayOneElement(t *testing.T) {
|
||||
_, err := RouteCommunityAttrs("", "x", `{"standard":[65000]}`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for single-element standard pair")
|
||||
}
|
||||
}
|
||||
Generated
+14
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
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>
|
||||
|
||||
Reference in new issue
Block a user