feat: enhance job filtering and display functionality in operations page. Introduce job search and filter options, allowing users to refine job listings by status, kind, and module. Update OperationsJobsTab to reflect total fetched jobs and improve overall user experience with better layout and responsiveness.
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 1m9s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
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:41:29 +07:00
parent 4aaaa30b1b
commit e1c4cbce93
2 changed files with 217 additions and 22 deletions
@@ -2,7 +2,7 @@
import type { RevisionDiff, RevisionPrefix, 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 { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/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 { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
type Props = { type Props = {
revisions: RevisionRow[]; revisions: RevisionRow[];
@@ -29,27 +29,52 @@
}: Props = $props(); }: Props = $props();
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */ /** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
function diffAdded(d: RevisionDiff | null): (string | RevisionPrefix)[] { function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return []; if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added; if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
if (Array.isArray(d.added)) return d.added; if (Array.isArray(d.added)) return d.added;
return []; return [];
} }
function diffRemoved(d: RevisionDiff | null): (string | RevisionPrefix)[] { function diffRemovedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return []; if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.removed)) return d.prefixes.removed; if (d.prefixes && Array.isArray(d.prefixes.removed)) return d.prefixes.removed;
if (Array.isArray(d.removed)) return d.removed; if (Array.isArray(d.removed)) return d.removed;
return []; return [];
} }
let addedSorted = $derived(diffData ? sortRevisionDiffItems(diffAddedRaw(diffData)) : []);
let removedSorted = $derived(diffData ? sortRevisionDiffItems(diffRemovedRaw(diffData)) : []);
let scrollAddedEl: HTMLDivElement | null = $state(null);
let scrollRemovedEl: HTMLDivElement | null = $state(null);
let syncScrollLock = false;
function syncFromAdded() {
if (syncScrollLock || !scrollAddedEl || !scrollRemovedEl) return;
syncScrollLock = true;
scrollRemovedEl.scrollTop = scrollAddedEl.scrollTop;
queueMicrotask(() => {
syncScrollLock = false;
});
}
function syncFromRemoved() {
if (syncScrollLock || !scrollAddedEl || !scrollRemovedEl) return;
syncScrollLock = true;
scrollAddedEl.scrollTop = scrollRemovedEl.scrollTop;
queueMicrotask(() => {
syncScrollLock = false;
});
}
</script> </script>
<Card> <Card class="min-h-0">
<CardHeader> <CardHeader>
<CardTitle class="text-base">Сравнение ревизий</CardTitle> <CardTitle class="text-base">Сравнение ревизий</CardTitle>
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription> <CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="min-h-0 space-y-4">
<div class="flex flex-col gap-2 sm:flex-row"> <div class="flex flex-col gap-2 sm:flex-row">
<select <select
value={diffRevA} value={diffRevA}
@@ -77,26 +102,84 @@
</div> </div>
{#if diffData} {#if diffData}
<div class="grid gap-3 sm:grid-cols-2"> <div
<div> class="border-border grid min-h-0 grid-cols-1 overflow-hidden rounded-md border sm:grid-cols-2"
<p class="mb-2 text-sm font-medium text-green-600">+ Добавлено ({diffAdded(diffData).length})</p> style="scrollbar-gutter: stable;"
<ScrollArea class="max-h-64 rounded border p-2"> >
{#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> <div class="border-border flex min-h-0 min-w-0 flex-col border-b sm:border-r sm:border-b-0">
<div
class="border-border bg-muted/60 flex shrink-0 items-center border-b px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
>
<span class="text-muted-foreground mr-2 w-10 shrink-0 select-none text-right">+</span>
<span>Добавлено ({addedSorted.length})</span>
</div>
<div
bind:this={scrollAddedEl}
class="bg-background/50 max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain"
onscroll={syncFromAdded}
>
{#if addedSorted.length === 0}
<p class="text-muted-foreground p-3 font-mono text-xs">Нет изменений</p>
{:else} {:else}
<p class="text-muted-foreground text-xs">Нет изменений</p> <table class="w-full border-collapse font-mono text-xs">
{/each} <tbody>
</ScrollArea> {#each addedSorted as line, i (`a-${i}-${line}`)}
<tr
class="border-border/50 hover:bg-muted/30 border-b border-l-2 border-l-green-500/80 bg-green-500/[0.08] dark:bg-green-500/15"
>
<td
class="text-muted-foreground w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] tabular-nums select-none"
>
{i + 1}
</td>
<td class="text-foreground max-w-0 break-all py-0.5 pr-3 pl-1 whitespace-pre-wrap">
{line}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div> </div>
<div>
<p class="mb-2 text-sm font-medium text-red-600">- Удалено ({diffRemoved(diffData).length})</p> <!-- Удалено -->
<ScrollArea class="max-h-64 rounded border p-2"> <div class="flex min-h-0 min-w-0 flex-col">
{#each diffRemoved(diffData) as p, idx (`removed-${idx}-${typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? 'item'}`)} <div
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p> class="border-border bg-muted/60 flex shrink-0 items-center border-b px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
>
<span class="text-muted-foreground mr-2 w-10 shrink-0 select-none text-right"></span>
<span>Удалено ({removedSorted.length})</span>
</div>
<div
bind:this={scrollRemovedEl}
class="bg-background/50 max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain"
onscroll={syncFromRemoved}
>
{#if removedSorted.length === 0}
<p class="text-muted-foreground p-3 font-mono text-xs">Нет изменений</p>
{:else} {:else}
<p class="text-muted-foreground text-xs">Нет изменений</p> <table class="w-full border-collapse font-mono text-xs">
{/each} <tbody>
</ScrollArea> {#each removedSorted as line, i (`r-${i}-${line}`)}
<tr
class="border-border/50 hover:bg-muted/30 border-b border-l-2 border-l-red-500/80 bg-red-500/[0.08] dark:bg-red-500/15"
>
<td
class="text-muted-foreground w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] tabular-nums select-none"
>
{i + 1}
</td>
<td class="text-foreground max-w-0 break-all py-0.5 pr-3 pl-1 whitespace-pre-wrap">
{line}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div> </div>
</div> </div>
{/if} {/if}
+112
View File
@@ -0,0 +1,112 @@
import type { RevisionPrefix } from '$lib/api/types.js';
export function prefixStringFromItem(p: string | RevisionPrefix): string {
if (typeof p === 'string') return p;
return p.prefix ?? '';
}
function parseIPv4Octets(addr: string): [number, number, number, number] | null {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(addr);
if (!m) return null;
const o = [m[1], m[2], m[3], m[4]].map((x) => parseInt(x, 10));
if (o.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) return null;
return [o[0], o[1], o[2], o[3]];
}
/** 8 групп по 16 бит + длина префикса (или null, если не распознали). */
function parseIPv6(addrPart: string): { words: number[]; pl: number } | null {
if (!addrPart.includes(':')) return null;
const zone = addrPart.indexOf('%');
if (zone >= 0) addrPart = addrPart.slice(0, zone);
const parts = addrPart.split('::');
if (parts.length > 2) return null;
const parseGroup = (h: string) => parseInt(h, 16);
if (parts.length === 1) {
const g = parts[0].split(':').filter(Boolean);
if (g.length === 8) {
return { words: g.map(parseGroup), pl: 128 };
}
// IPv4-хвост: x:x:x:x:x:x:a.b.c.d (6 групп + IPv4)
if (g.length === 7 && g[6].includes('.')) {
const o = parseIPv4Octets(g[6]);
if (!o) return null;
const w = g.slice(0, 6).map(parseGroup);
w.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
return { words: w, pl: 128 };
}
return null;
}
const left = parts[0] ? parts[0].split(':').filter(Boolean) : [];
const right = parts[1] ? parts[1].split(':').filter(Boolean) : [];
const lastR = right[right.length - 1];
if (lastR && lastR.includes('.')) {
const o = parseIPv4Octets(lastR);
if (!o) return null;
const rWords = right.slice(0, -1).map(parseGroup);
rWords.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
const missing = 8 - left.length - rWords.length;
if (missing < 0) return null;
return { words: [...left.map(parseGroup), ...Array(missing).fill(0), ...rWords], pl: 128 };
}
const missing = 8 - left.length - right.length;
if (missing < 0) return null;
return { words: [...left.map(parseGroup), ...Array(missing).fill(0), ...right.map(parseGroup)], pl: 128 };
}
type SortKey =
| { t: 0; v4: [number, number, number, number]; pl: number }
| { t: 1; v6: number[]; pl: number }
| { t: 2; s: string };
function sortKeyForCidr(s: string): SortKey {
const slash = s.indexOf('/');
const addr = slash >= 0 ? s.slice(0, slash) : s;
const plStr = slash >= 0 ? s.slice(slash + 1) : '';
const plNum = plStr ? parseInt(plStr, 10) : NaN;
const v4 = parseIPv4Octets(addr);
if (v4 && !addr.includes(':')) {
const pl = Number.isFinite(plNum) ? plNum : 32;
return { t: 0, v4, pl };
}
const v6 = parseIPv6(addr);
if (v6) {
const pl = Number.isFinite(plNum) ? plNum : v6.pl;
return { t: 1, v6: v6.words, pl };
}
return { t: 2, s: s.toLowerCase() };
}
function compareKeys(a: SortKey, b: SortKey): number {
if (a.t !== b.t) return a.t - b.t;
if (a.t === 2 && b.t === 2) {
return a.s.localeCompare(b.s, 'en', { numeric: true, sensitivity: 'base' });
}
if (a.t === 0 && b.t === 0) {
for (let i = 0; i < 4; i++) {
if (a.v4[i] !== b.v4[i]) return a.v4[i] - b.v4[i];
}
return a.pl - b.pl;
}
if (a.t === 1 && b.t === 1) {
for (let i = 0; i < 8; i++) {
if (a.v6[i] !== b.v6[i]) return a.v6[i] - b.v6[i];
}
return a.pl - b.pl;
}
return 0;
}
/** Сортировка CIDR и прочих строк префиксов (as:, домены и т.д.) для отображения. */
export function sortPrefixStrings(strings: string[]): string[] {
return [...strings].sort((a, b) => compareKeys(sortKeyForCidr(a), sortKeyForCidr(b)));
}
export function sortRevisionDiffItems(items: (string | RevisionPrefix)[]): string[] {
return sortPrefixStrings(items.map(prefixStringFromItem));
}