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
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:
@@ -191,9 +191,18 @@ export type RevisionPreview = {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ответ GET /v1/revisions/{a}/diff/{b}: префиксы в `prefixes` (источник правды в бэкенде). */
|
||||||
export type RevisionDiff = {
|
export type RevisionDiff = {
|
||||||
added: RevisionPrefix[];
|
revision_a?: string;
|
||||||
removed: RevisionPrefix[];
|
revision_b?: string;
|
||||||
|
prefixes?: {
|
||||||
|
added: string[];
|
||||||
|
removed: string[];
|
||||||
|
unchanged_count?: number;
|
||||||
|
};
|
||||||
|
/** Устаревший/нормализованный вид — см. нормализацию в UI */
|
||||||
|
added?: (string | RevisionPrefix)[];
|
||||||
|
removed?: (string | RevisionPrefix)[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { 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 { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
@@ -27,6 +27,21 @@
|
|||||||
onLoadDiff,
|
onLoadDiff,
|
||||||
formatDate
|
formatDate
|
||||||
}: Props = $props();
|
}: 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>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -64,9 +79,9 @@
|
|||||||
{#if diffData}
|
{#if diffData}
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<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">
|
<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>
|
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||||
@@ -74,9 +89,9 @@
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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>
|
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
<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 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 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 { cn } from '$lib/utils.js';
|
||||||
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
|
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
import JobReportTableBlock from './job-report-table-block.svelte';
|
import JobReportTableBlock from './job-report-table-block.svelte';
|
||||||
@@ -23,6 +31,8 @@
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobs: JobRow[];
|
jobs: JobRow[];
|
||||||
|
/** Сколько задач вернул API до клиентских фильтров */
|
||||||
|
jobsFetchedTotal?: number;
|
||||||
jobsLoading: boolean;
|
jobsLoading: boolean;
|
||||||
moduleNameById: ReadonlyMap<string, string>;
|
moduleNameById: ReadonlyMap<string, string>;
|
||||||
expandedJobIds: SvelteSet<string>;
|
expandedJobIds: SvelteSet<string>;
|
||||||
@@ -43,6 +53,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
jobs,
|
jobs,
|
||||||
|
jobsFetchedTotal,
|
||||||
jobsLoading,
|
jobsLoading,
|
||||||
moduleNameById,
|
moduleNameById,
|
||||||
expandedJobIds,
|
expandedJobIds,
|
||||||
@@ -72,7 +83,14 @@
|
|||||||
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
<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">
|
<div class="min-w-0 flex-1">
|
||||||
<CardTitle class="text-base">Задачи</CardTitle>
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -141,21 +159,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">Статус</p>
|
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
|
||||||
<Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge>
|
>
|
||||||
|
<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>
|
||||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
<div
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">Создана</p>
|
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
|
||||||
<p>{formatDate(job.created_at)}</p>
|
>
|
||||||
|
<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>
|
||||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
<div
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">Запущена</p>
|
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
||||||
<p>{formatDate(job.started_at)}</p>
|
>
|
||||||
|
<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>
|
||||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
<div
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">Завершена</p>
|
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
||||||
<p>{formatDate(job.finished_at)}</p>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,20 +337,48 @@
|
|||||||
</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
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">Агрегация</p>
|
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>
|
<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
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">Домены</p>
|
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>
|
<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
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">ASN</p>
|
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>
|
<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
|
||||||
<p class="text-[11px] text-muted-foreground uppercase">CDN/IP Range</p>
|
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">
|
<p class="text-sm font-semibold">
|
||||||
{jobReport.cdn.length}/{jobReport.ipRanges.length}
|
{jobReport.cdn.length}/{jobReport.ipRanges.length}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||||
import Bird from '@lucide/svelte/icons/bird';
|
import Bird from '@lucide/svelte/icons/bird';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
@@ -36,24 +37,48 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<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="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="flex min-w-0 flex-1 gap-3">
|
||||||
<p class="font-semibold">Apply all speakers</p>
|
<div
|
||||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех спикерах</p>
|
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>
|
</div>
|
||||||
<Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}>
|
<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
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="min-w-0 flex-1 space-y-1">
|
<div class="flex min-w-0 flex-1 gap-3">
|
||||||
<p class="font-semibold">BIRD Reload</p>
|
<div
|
||||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
|
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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -61,17 +86,26 @@
|
|||||||
onclick={onReload}
|
onclick={onReload}
|
||||||
disabled={reloading}
|
disabled={reloading}
|
||||||
>
|
>
|
||||||
<RotateCcw />
|
<RotateCcw class="size-4" aria-hidden="true" />
|
||||||
Reload
|
Reload
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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="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="min-w-0 flex-1 space-y-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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>
|
<p class="font-semibold">Состояние BIRD</p>
|
||||||
{#if birdStatus}
|
{#if birdStatus}
|
||||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
|
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
|
||||||
|
|||||||
+95
-13
@@ -5,12 +5,14 @@
|
|||||||
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, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const resolve = (path: string) => path as any;
|
const resolve = (path: string) => path as any;
|
||||||
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
import XCircle from '@lucide/svelte/icons/x-circle';
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
import Network from '@lucide/svelte/icons/network';
|
import GitBranch from '@lucide/svelte/icons/git-branch';
|
||||||
|
import Radio from '@lucide/svelte/icons/radio';
|
||||||
import Activity from '@lucide/svelte/icons/activity';
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
@@ -48,12 +50,80 @@ const resolve = (path: string) => path as any;
|
|||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-1',
|
||||||
|
bg: 'bg-chart-1/5',
|
||||||
|
iconBg: 'bg-chart-1/15',
|
||||||
|
iconText: 'text-chart-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-2',
|
||||||
|
bg: 'bg-chart-2/5',
|
||||||
|
iconBg: 'bg-chart-2/15',
|
||||||
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-3',
|
||||||
|
bg: 'bg-chart-3/5',
|
||||||
|
iconBg: 'bg-chart-3/15',
|
||||||
|
iconText: 'text-chart-3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-5',
|
||||||
|
bg: 'bg-chart-5/5',
|
||||||
|
iconBg: 'bg-chart-5/15',
|
||||||
|
iconText: 'text-chart-5'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
const stats = $derived([
|
const stats = $derived([
|
||||||
{ label: 'Модули', value: modules, href: '/modules', icon: Boxes, description: 'AS, CDN, домены, IP' },
|
{
|
||||||
{ label: 'Пиры', value: peers, href: '/network', icon: Network, description: 'BGP-соседи' },
|
label: 'Модули',
|
||||||
{ label: 'Спикеры', value: speakers, href: '/network', icon: Network, description: 'BIRD-агенты' },
|
value: modules,
|
||||||
{ label: 'Ревизии', value: revisions, href: '/operations', icon: Activity, description: 'История конфигураций' },
|
href: '/modules',
|
||||||
{ label: 'Активных задач', value: runningJobs, href: '/operations', icon: Clock, description: 'Выполняются сейчас' },
|
icon: Boxes,
|
||||||
|
description: 'AS, CDN, домены, IP',
|
||||||
|
accent: statAccents[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Пиры',
|
||||||
|
value: peers,
|
||||||
|
href: '/network',
|
||||||
|
icon: GitBranch,
|
||||||
|
description: 'BGP-соседи',
|
||||||
|
accent: statAccents[1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Спикеры',
|
||||||
|
value: speakers,
|
||||||
|
href: '/network',
|
||||||
|
icon: Radio,
|
||||||
|
description: 'BIRD-агенты',
|
||||||
|
accent: statAccents[2]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ревизии',
|
||||||
|
value: revisions,
|
||||||
|
href: '/operations',
|
||||||
|
icon: Activity,
|
||||||
|
description: 'История конфигураций',
|
||||||
|
accent: statAccents[3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Активных задач',
|
||||||
|
value: runningJobs,
|
||||||
|
href: '/operations',
|
||||||
|
icon: Clock,
|
||||||
|
description: 'Выполняются сейчас',
|
||||||
|
accent: statAccents[4]
|
||||||
|
}
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -83,18 +153,30 @@ const resolve = (path: string) => path as any;
|
|||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each stats as stat (stat.label)}
|
{#each stats as stat (stat.label)}
|
||||||
{@const Icon = stat.icon}
|
{@const Icon = stat.icon}
|
||||||
<Card class="hover:border-primary/40 transition-colors">
|
{@const a = stat.accent}
|
||||||
|
<Card
|
||||||
|
class={cn(
|
||||||
|
'overflow-hidden border-l-4 shadow-sm transition-colors hover:border-primary/35',
|
||||||
|
a.border,
|
||||||
|
a.bg
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader class="pb-2">
|
<CardHeader class="pb-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<CardDescription class="flex items-center gap-1.5">
|
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||||
<Icon class="size-4" />
|
<span
|
||||||
{stat.label}
|
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class={cn('size-4', a.iconText)} />
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{stat.label}</span>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<Button variant="ghost" size="icon-sm" href={resolve(stat.href)}>
|
<Button variant="ghost" size="icon-sm" href={resolve(stat.href)}>
|
||||||
<ArrowRight class="size-3.5" />
|
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-3xl font-bold">
|
<CardTitle class="text-3xl font-bold tabular-nums">
|
||||||
{loading ? '—' : stat.value}
|
{loading ? '—' : stat.value}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -28,6 +28,10 @@
|
|||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
/* Семантические акценты (иконки, границы карточек) */
|
||||||
|
--success: oklch(0.55 0.16 145);
|
||||||
|
--warning: oklch(0.72 0.16 75);
|
||||||
|
--info: oklch(0.55 0.16 250);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
@@ -62,6 +66,9 @@
|
|||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--success: oklch(0.72 0.17 150);
|
||||||
|
--warning: oklch(0.82 0.16 85);
|
||||||
|
--info: oklch(0.72 0.12 250);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
@@ -100,6 +107,9 @@
|
|||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-success: var(--success);
|
||||||
|
--color-warning: var(--warning);
|
||||||
|
--color-info: var(--info);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
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 OperationsJobsFilters from '$lib/components/operations/OperationsJobsFilters.svelte';
|
||||||
import type {
|
import type {
|
||||||
JobDetailedReport,
|
JobDetailedReport,
|
||||||
JobLogEntry,
|
JobLogEntry,
|
||||||
@@ -104,6 +105,11 @@
|
|||||||
// Jobs
|
// Jobs
|
||||||
let jobs = $state<JobRow[]>([]);
|
let jobs = $state<JobRow[]>([]);
|
||||||
let jobsLoading = $state(false);
|
let jobsLoading = $state(false);
|
||||||
|
let jobSearchQ = $state('');
|
||||||
|
let jobFilterStatus = $state('');
|
||||||
|
let jobFilterKind = $state('');
|
||||||
|
let jobFilterModule = $state('');
|
||||||
|
let jobActiveOnly = $state(false);
|
||||||
let moduleNameById = $state(new Map<string, string>());
|
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);
|
||||||
@@ -167,7 +173,11 @@
|
|||||||
async function loadJobs() {
|
async function loadJobs() {
|
||||||
jobsLoading = true;
|
jobsLoading = true;
|
||||||
try {
|
try {
|
||||||
const j = await apiJSON<JobsResponse>('/v1/jobs?limit=100');
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', '200');
|
||||||
|
if (jobFilterStatus) params.set('status', jobFilterStatus);
|
||||||
|
if (jobFilterKind) params.set('kind', jobFilterKind);
|
||||||
|
const j = await apiJSON<JobsResponse>(`/v1/jobs?${params.toString()}`);
|
||||||
jobs = j.items;
|
jobs = j.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
toast.error(e instanceof Error ? e.message : String(e));
|
||||||
@@ -176,6 +186,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetJobFilters() {
|
||||||
|
jobSearchQ = '';
|
||||||
|
jobFilterStatus = '';
|
||||||
|
jobFilterKind = '';
|
||||||
|
jobFilterModule = '';
|
||||||
|
jobActiveOnly = false;
|
||||||
|
void loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobsErrorsChip() {
|
||||||
|
jobFilterStatus = 'failed';
|
||||||
|
jobActiveOnly = false;
|
||||||
|
void loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onJobsActiveChip() {
|
||||||
|
jobFilterStatus = '';
|
||||||
|
jobActiveOnly = true;
|
||||||
|
void loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobMatchesSearch(job: JobRow, q: string): boolean {
|
||||||
|
const lower = q.trim().toLowerCase();
|
||||||
|
if (!lower) return true;
|
||||||
|
const title = jobKindTitle(job, moduleNameById).toLowerCase();
|
||||||
|
const err = (job.error ?? '').toLowerCase();
|
||||||
|
const metaStr = JSON.stringify(job.meta ?? {}).toLowerCase();
|
||||||
|
return (
|
||||||
|
job.job_id.toLowerCase().includes(lower) ||
|
||||||
|
job.kind.toLowerCase().includes(lower) ||
|
||||||
|
job.status.toLowerCase().includes(lower) ||
|
||||||
|
err.includes(lower) ||
|
||||||
|
title.includes(lower) ||
|
||||||
|
metaStr.includes(lower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobsDisplayed = $derived.by(() => {
|
||||||
|
let list = jobs;
|
||||||
|
if (jobActiveOnly) {
|
||||||
|
list = list.filter((j) => j.status === 'running' || j.status === 'queued');
|
||||||
|
}
|
||||||
|
if (jobFilterModule) {
|
||||||
|
list = list.filter((j) => {
|
||||||
|
const mid =
|
||||||
|
j.meta && typeof j.meta === 'object' && j.meta !== null && 'module_id' in j.meta
|
||||||
|
? String((j.meta as Record<string, unknown>).module_id)
|
||||||
|
: '';
|
||||||
|
return j.kind === 'module_refresh' && mid === jobFilterModule;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const q = jobSearchQ.trim();
|
||||||
|
if (q) {
|
||||||
|
list = list.filter((j) => jobMatchesSearch(j, q));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobModuleOptions = $derived(
|
||||||
|
[...moduleNameById.entries()]
|
||||||
|
.map(([id, name]) => ({ id, name }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'ru'))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onJobFilterStatusChange(v: string) {
|
||||||
|
jobFilterStatus = v;
|
||||||
|
await loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onJobFilterKindChange(v: string) {
|
||||||
|
jobFilterKind = v;
|
||||||
|
await loadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadModules() {
|
async function loadModules() {
|
||||||
try {
|
try {
|
||||||
const r = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
const r = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
||||||
@@ -645,9 +729,26 @@
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="jobs" class="mt-4">
|
<TabsContent value="jobs" class="mt-4 space-y-4">
|
||||||
|
<OperationsJobsFilters
|
||||||
|
searchQ={jobSearchQ}
|
||||||
|
onSearchQChange={(v) => (jobSearchQ = v)}
|
||||||
|
filterStatus={jobFilterStatus}
|
||||||
|
onFilterStatusChange={onJobFilterStatusChange}
|
||||||
|
filterKind={jobFilterKind}
|
||||||
|
onFilterKindChange={onJobFilterKindChange}
|
||||||
|
filterModuleId={jobFilterModule}
|
||||||
|
onFilterModuleIdChange={(v) => (jobFilterModule = v)}
|
||||||
|
activeOnly={jobActiveOnly}
|
||||||
|
onErrorsChip={onJobsErrorsChip}
|
||||||
|
onActiveChip={onJobsActiveChip}
|
||||||
|
onResetFilters={resetJobFilters}
|
||||||
|
moduleOptions={jobModuleOptions}
|
||||||
|
disabled={jobsLoading}
|
||||||
|
/>
|
||||||
<OperationsJobsTab
|
<OperationsJobsTab
|
||||||
{jobs}
|
jobs={jobsDisplayed}
|
||||||
|
jobsFetchedTotal={jobs.length}
|
||||||
{jobsLoading}
|
{jobsLoading}
|
||||||
{moduleNameById}
|
{moduleNameById}
|
||||||
{expandedJobIds}
|
{expandedJobIds}
|
||||||
|
|||||||
Reference in New Issue
Block a user