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*$`)
|
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.
|
// RouteCommunityAttrs returns BIRD 2 lines (indented with 4 spaces) for use inside a static `route … { … }` block.
|
||||||
// Empty string means no community attributes.
|
// Empty string means no community attributes.
|
||||||
func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) {
|
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 s, ok := parseLargeFromJSONLoose(raw); ok {
|
||||||
|
if err := validateCommunityRouteBody(s); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
_ = kind // reserved for future kinds (e.g. extended communities)
|
_ = kind // reserved for future kinds (e.g. extended communities)
|
||||||
|
|
||||||
if m := standardPairRE.FindStringSubmatch(name); len(m) == 3 {
|
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
|
var obj map[string]any
|
||||||
if json.Unmarshal([]byte(raw), &obj) == nil {
|
if json.Unmarshal([]byte(raw), &obj) == nil {
|
||||||
if v, ok := obj["standard"].(string); ok {
|
if v, ok := obj["standard"].(string); ok {
|
||||||
if m := standardPairRE.FindStringSubmatch(v); len(m) == 3 {
|
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 {
|
if arr, ok := obj["standard"].([]any); ok {
|
||||||
a, aok := numberToUint32String(arr[0])
|
if len(arr) == 1 {
|
||||||
b, bok := numberToUint32String(arr[1])
|
return "", fmt.Errorf(`birdfmt: value_json "standard" must be a pair [a,b] or "a:b" string, got single element`)
|
||||||
if aok && bok {
|
}
|
||||||
return standardAddLine(a, b)
|
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
|
var strVal string
|
||||||
if json.Unmarshal([]byte(raw), &strVal) == nil {
|
if json.Unmarshal([]byte(raw), &strVal) == nil {
|
||||||
if m := standardPairRE.FindStringSubmatch(strVal); len(m) == 3 {
|
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
|
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) {
|
func standardAddLine(a, b string) (string, error) {
|
||||||
ai, err1 := strconv.ParseUint(strings.TrimSpace(a), 10, 16)
|
ai, err1 := strconv.ParseUint(strings.TrimSpace(a), 10, 16)
|
||||||
bi, err2 := strconv.ParseUint(strings.TrimSpace(b), 10, 16)
|
bi, err2 := strconv.ParseUint(strings.TrimSpace(b), 10, 16)
|
||||||
@@ -111,6 +158,11 @@ func numberToUint32String(v any) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return strconv.FormatUint(uint64(x), 10), true
|
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:
|
case string:
|
||||||
n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32)
|
n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ func TestRouteCommunityAttrs_LargeJSON(t *testing.T) {
|
|||||||
t.Fatalf("got %q", s)
|
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",
|
"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