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
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:
@@ -2,7 +2,7 @@
|
||||
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';
|
||||
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
||||
|
||||
type Props = {
|
||||
revisions: RevisionRow[];
|
||||
@@ -29,27 +29,52 @@
|
||||
}: Props = $props();
|
||||
|
||||
/** Бэкенд отдаёт `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.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)[] {
|
||||
function diffRemovedRaw(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 [];
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<Card class="min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Сравнение ревизий</CardTitle>
|
||||
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<CardContent class="min-h-0 space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<select
|
||||
value={diffRevA}
|
||||
@@ -77,26 +102,84 @@
|
||||
</div>
|
||||
|
||||
{#if diffData}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<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 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 grid min-h-0 grid-cols-1 overflow-hidden rounded-md border sm:grid-cols-2"
|
||||
style="scrollbar-gutter: stable;"
|
||||
>
|
||||
<!-- + Добавлено -->
|
||||
<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}
|
||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
<table class="w-full border-collapse font-mono text-xs">
|
||||
<tbody>
|
||||
{#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>
|
||||
<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 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>
|
||||
|
||||
<!-- − Удалено -->
|
||||
<div class="flex min-h-0 min-w-0 flex-col">
|
||||
<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-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}
|
||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
<table class="w-full border-collapse font-mono text-xs">
|
||||
<tbody>
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user