feat: update RevisionDiff type and enhance OperationsDiffTab component for better diff handling. Introduce optional prefixes structure in RevisionDiff and refactor diffAdded/diffRemoved functions to utilize new data structure, improving clarity and functionality in the UI.
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m5s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m4s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-07 00:26:23 +07:00
parent d83efe24ef
commit 4aaaa30b1b
8 changed files with 536 additions and 56 deletions
+11 -2
View File
@@ -191,9 +191,18 @@ export type RevisionPreview = {
[key: string]: unknown;
};
/** Ответ GET /v1/revisions/{a}/diff/{b}: префиксы в `prefixes` (источник правды в бэкенде). */
export type RevisionDiff = {
added: RevisionPrefix[];
removed: RevisionPrefix[];
revision_a?: string;
revision_b?: string;
prefixes?: {
added: string[];
removed: string[];
unchanged_count?: number;
};
/** Устаревший/нормализованный вид — см. нормализацию в UI */
added?: (string | RevisionPrefix)[];
removed?: (string | RevisionPrefix)[];
[key: string]: unknown;
};
@@ -1,5 +1,5 @@
<script lang="ts">
import type { RevisionDiff, RevisionRow } from '$lib/api/types.js';
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.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';
@@ -27,6 +27,21 @@
onLoadDiff,
formatDate
}: Props = $props();
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
function diffAdded(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
if (Array.isArray(d.added)) return d.added;
return [];
}
function diffRemoved(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.removed)) return d.prefixes.removed;
if (Array.isArray(d.removed)) return d.removed;
return [];
}
</script>
<Card>
@@ -64,9 +79,9 @@
{#if diffData}
<div class="grid gap-3 sm:grid-cols-2">
<div>
<p class="mb-2 text-sm font-medium text-green-600">+ Добавлено ({diffData.added?.length ?? 0})</p>
<p class="mb-2 text-sm font-medium text-green-600">+ Добавлено ({diffAdded(diffData).length})</p>
<ScrollArea class="max-h-64 rounded border p-2">
{#each diffData.added ?? [] as p, idx (`added-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)}
{#each diffAdded(diffData) as p, idx (`added-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)}
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет изменений</p>
@@ -74,9 +89,9 @@
</ScrollArea>
</div>
<div>
<p class="mb-2 text-sm font-medium text-red-600">- Удалено ({diffData.removed?.length ?? 0})</p>
<p class="mb-2 text-sm font-medium text-red-600">- Удалено ({diffRemoved(diffData).length})</p>
<ScrollArea class="max-h-64 rounded border p-2">
{#each diffData.removed ?? [] as p, idx (`removed-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)}
{#each diffRemoved(diffData) as p, idx (`removed-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)}
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p>
{:else}
<p class="text-muted-foreground text-xs">Нет изменений</p>
@@ -0,0 +1,155 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
type Props = {
searchQ: string;
onSearchQChange: (v: string) => void;
filterStatus: string;
onFilterStatusChange: (v: string) => void;
filterKind: string;
onFilterKindChange: (v: string) => void;
filterModuleId: string;
onFilterModuleIdChange: (v: string) => void;
activeOnly: boolean;
onErrorsChip: () => void;
onActiveChip: () => void;
onResetFilters: () => void;
moduleOptions: { id: string; name: string }[];
disabled?: boolean;
};
let {
searchQ,
onSearchQChange,
filterStatus,
onFilterStatusChange,
filterKind,
onFilterKindChange,
filterModuleId,
onFilterModuleIdChange,
activeOnly,
onErrorsChip,
onActiveChip,
onResetFilters,
moduleOptions,
disabled = false
}: Props = $props();
const statusOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все статусы' },
{ value: 'queued', label: 'queued' },
{ value: 'running', label: 'running' },
{ value: 'succeeded', label: 'succeeded' },
{ value: 'failed', label: 'failed' },
{ value: 'cancelled', label: 'cancelled' }
];
const kindOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все типы' },
{ value: 'module_refresh', label: 'module_refresh' },
{ value: 'deploy_apply', label: 'deploy_apply' },
{ value: 'revision_rollback', label: 'revision_rollback' },
{ value: 'bird_reload', label: 'bird_reload' }
];
</script>
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
<div class="flex flex-wrap items-center gap-2">
<Filter class="text-muted-foreground size-4 shrink-0" aria-hidden="true" />
<span class="text-sm font-medium">Фильтры задач</span>
<div class="ml-auto flex flex-wrap gap-1.5">
<Button
type="button"
variant={filterStatus === 'failed' && !activeOnly ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onErrorsChip}
>
Только ошибки
</Button>
<Button
type="button"
variant={activeOnly ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onActiveChip}
>
В работе
</Button>
<Button type="button" variant="ghost" size="sm" class="h-7 text-xs" {disabled} onclick={onResetFilters}>
<X class="size-3.5" aria-hidden="true" />
Сброс
</Button>
</div>
</div>
<div class="relative">
<Search
class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Поиск по ID, типу, статусу, ошибке, meta…"
class="h-9 pl-9"
value={searchQ}
oninput={(e) => onSearchQChange((e.currentTarget as HTMLInputElement).value)}
{disabled}
autocomplete="off"
aria-label="Текстовый поиск по задачам"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-1.5">
<Label for="job-filter-status" class="text-xs">Статус (API)</Label>
<select
id="job-filter-status"
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
value={filterStatus}
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
{#each statusOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label for="job-filter-kind" class="text-xs">Тип задачи (API)</Label>
<select
id="job-filter-kind"
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
value={filterKind}
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
{#each kindOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
<select
id="job-filter-module"
class="border-border bg-background h-9 w-full rounded-md border px-2 text-sm"
value={filterModuleId}
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
<option value="">Все модули</option>
{#each moduleOptions as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</div>
</div>
</div>
@@ -15,6 +15,14 @@
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import CircleDot from '@lucide/svelte/icons/circle-dot';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import PlayCircle from '@lucide/svelte/icons/play-circle';
import Flag from '@lucide/svelte/icons/flag';
import Layers from '@lucide/svelte/icons/layers';
import Globe from '@lucide/svelte/icons/globe';
import Binary from '@lucide/svelte/icons/binary';
import Link2 from '@lucide/svelte/icons/link-2';
import { cn } from '$lib/utils.js';
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
import JobReportTableBlock from './job-report-table-block.svelte';
@@ -23,6 +31,8 @@
type Props = {
jobs: JobRow[];
/** Сколько задач вернул API до клиентских фильтров */
jobsFetchedTotal?: number;
jobsLoading: boolean;
moduleNameById: ReadonlyMap<string, string>;
expandedJobIds: SvelteSet<string>;
@@ -43,6 +53,7 @@
let {
jobs,
jobsFetchedTotal,
jobsLoading,
moduleNameById,
expandedJobIds,
@@ -72,7 +83,14 @@
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Задачи</CardTitle>
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>Фоновые задачи (ingest, apply, refresh)</span>
{#if jobsFetchedTotal !== undefined}
<span class="text-muted-foreground font-normal tabular-nums">
· Показано {jobs.length} из {jobsFetchedTotal}
</span>
{/if}
</CardDescription>
</div>
<Button
variant="outline"
@@ -141,21 +159,49 @@
</div>
<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-[11px] text-muted-foreground uppercase">Статус</p>
<Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge>
<div
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
>
<p
class="text-chart-1 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
Статус
</p>
<Badge class="mt-1" variant={jobStatusVariant(job.status)}>{job.status}</Badge>
</div>
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-[11px] text-muted-foreground uppercase">Создана</p>
<p>{formatDate(job.created_at)}</p>
<div
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
>
<p
class="text-chart-2 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
Создана
</p>
<p class="mt-1">{formatDate(job.created_at)}</p>
</div>
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-[11px] text-muted-foreground uppercase">Запущена</p>
<p>{formatDate(job.started_at)}</p>
<div
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
>
<p
class="text-chart-3 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
Запущена
</p>
<p class="mt-1">{formatDate(job.started_at)}</p>
</div>
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
<p class="text-[11px] text-muted-foreground uppercase">Завершена</p>
<p>{formatDate(job.finished_at)}</p>
<div
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
>
<p
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
Завершена
</p>
<p class="mt-1">{formatDate(job.finished_at)}</p>
</div>
</div>
</div>
@@ -291,20 +337,48 @@
</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-[11px] text-muted-foreground uppercase">Агрегация</p>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
>
<p
class="text-chart-1 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
Агрегация
</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-[11px] text-muted-foreground uppercase">Домены</p>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
>
<p
class="text-chart-2 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
Домены
</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-[11px] text-muted-foreground uppercase">ASN</p>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
>
<p
class="text-chart-3 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
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-[11px] text-muted-foreground uppercase">CDN/IP Range</p>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
>
<p
class="text-chart-4 flex items-center gap-1 text-[11px] font-medium uppercase"
>
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
CDN / IP Range
</p>
<p class="text-sm font-semibold">
{jobReport.cdn.length}/{jobReport.ipRanges.length}
</p>
@@ -7,6 +7,7 @@
import Play from '@lucide/svelte/icons/play';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Bird from '@lucide/svelte/icons/bird';
import { cn } from '$lib/utils.js';
type Props = {
applying: boolean;
@@ -36,24 +37,48 @@
</script>
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Card class="min-w-0 p-4">
<Card
class={cn(
'min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm'
)}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Apply all speakers</p>
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех спикерах</p>
<div class="flex min-w-0 flex-1 gap-3">
<div
class="bg-chart-1/20 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<Play class="text-chart-1 size-5" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Apply all speakers</p>
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех спикерах</p>
</div>
</div>
<Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}>
<Play />
<Play class="size-4" aria-hidden="true" />
Apply
</Button>
</div>
</Card>
<Card class="min-w-0 p-4">
<Card
class={cn(
'min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm'
)}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">BIRD Reload</p>
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
<div class="flex min-w-0 flex-1 gap-3">
<div
class="bg-chart-4/20 flex size-11 shrink-0 items-center justify-center rounded-xl"
aria-hidden="true"
>
<RotateCcw class="text-chart-4 size-5" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">BIRD Reload</p>
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
</div>
</div>
<Button
variant="outline"
@@ -61,17 +86,26 @@
onclick={onReload}
disabled={reloading}
>
<RotateCcw />
<RotateCcw class="size-4" aria-hidden="true" />
Reload
</Button>
</div>
</Card>
<Card class="min-w-0 p-4 sm:col-span-2 xl:col-span-1">
<Card
class={cn(
'min-w-0 overflow-hidden border-l-4 border-l-info bg-info/10 p-4 shadow-sm sm:col-span-2 xl:col-span-1'
)}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<Bird class="text-muted-foreground size-4 shrink-0" />
<div
class="bg-info/15 mr-0.5 flex size-9 items-center justify-center rounded-lg"
aria-hidden="true"
>
<Bird class="text-info size-5 shrink-0" />
</div>
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>