diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index da406a1..19f6d93 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -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; }; diff --git a/web/src/lib/components/operations/OperationsDiffTab.svelte b/web/src/lib/components/operations/OperationsDiffTab.svelte index 4f2a538..f110c07 100644 --- a/web/src/lib/components/operations/OperationsDiffTab.svelte +++ b/web/src/lib/components/operations/OperationsDiffTab.svelte @@ -1,5 +1,5 @@ @@ -64,9 +79,9 @@ {#if diffData}
-

+ Добавлено ({diffData.added?.length ?? 0})

+

+ Добавлено ({diffAdded(diffData).length})

- {#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'}`)}

{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}

{:else}

Нет изменений

@@ -74,9 +89,9 @@
-

- Удалено ({diffData.removed?.length ?? 0})

+

- Удалено ({diffRemoved(diffData).length})

- {#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'}`)}

{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}

{:else}

Нет изменений

diff --git a/web/src/lib/components/operations/OperationsJobsFilters.svelte b/web/src/lib/components/operations/OperationsJobsFilters.svelte new file mode 100644 index 0000000..d28dafe --- /dev/null +++ b/web/src/lib/components/operations/OperationsJobsFilters.svelte @@ -0,0 +1,155 @@ + + +
+
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/web/src/lib/components/operations/OperationsJobsTab.svelte b/web/src/lib/components/operations/OperationsJobsTab.svelte index a98b5e4..bc3991f 100644 --- a/web/src/lib/components/operations/OperationsJobsTab.svelte +++ b/web/src/lib/components/operations/OperationsJobsTab.svelte @@ -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; expandedJobIds: SvelteSet; @@ -43,6 +53,7 @@ let { jobs, + jobsFetchedTotal, jobsLoading, moduleNameById, expandedJobIds, @@ -72,7 +83,14 @@
Задачи - Фоновые задачи (ingest, apply, refresh) + + Фоновые задачи (ingest, apply, refresh) + {#if jobsFetchedTotal !== undefined} + + · Показано {jobs.length} из {jobsFetchedTotal} + + {/if} +
- +
-
-

BIRD Reload

-

Перезагрузить конфигурацию BIRD на всех спикерах

+
+ +
+

BIRD Reload

+

Перезагрузить конфигурацию BIRD на всех спикерах

+
- +
- +

Состояние BIRD

{#if birdStatus} {birdHealthyShortLabel(birdStatus.healthy)} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 887395f..a86d436 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -5,12 +5,14 @@ import { Badge } from '$lib/components/ui/badge/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 { cn } from '$lib/utils.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const resolve = (path: string) => path as any; import CheckCircle from '@lucide/svelte/icons/check-circle'; import XCircle from '@lucide/svelte/icons/x-circle'; 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 Clock from '@lucide/svelte/icons/clock'; import ArrowRight from '@lucide/svelte/icons/arrow-right'; @@ -48,12 +50,80 @@ const resolve = (path: string) => path as any; 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([ - { label: 'Модули', value: modules, href: '/modules', icon: Boxes, description: 'AS, CDN, домены, IP' }, - { label: 'Пиры', value: peers, href: '/network', icon: Network, description: 'BGP-соседи' }, - { label: 'Спикеры', value: speakers, href: '/network', icon: Network, description: 'BIRD-агенты' }, - { label: 'Ревизии', value: revisions, href: '/operations', icon: Activity, description: 'История конфигураций' }, - { label: 'Активных задач', value: runningJobs, href: '/operations', icon: Clock, description: 'Выполняются сейчас' }, + { + label: 'Модули', + value: modules, + href: '/modules', + 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] + } ]); @@ -83,18 +153,30 @@ const resolve = (path: string) => path as any;
{#each stats as stat (stat.label)} {@const Icon = stat.icon} - + {@const a = stat.accent} + -
- - - {stat.label} +
+ + + {stat.label}
- + {loading ? '—' : stat.value} diff --git a/web/src/routes/layout.css b/web/src/routes/layout.css index 40e9739..c983beb 100644 --- a/web/src/routes/layout.css +++ b/web/src/routes/layout.css @@ -28,6 +28,10 @@ --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --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-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); @@ -62,6 +66,9 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --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-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); @@ -100,6 +107,9 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-success: var(--success); + --color-warning: var(--warning); + --color-info: var(--info); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index faa4628..886b51e 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -47,6 +47,7 @@ import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte'; import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte'; import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte'; + import OperationsJobsFilters from '$lib/components/operations/OperationsJobsFilters.svelte'; import type { JobDetailedReport, JobLogEntry, @@ -104,6 +105,11 @@ // Jobs let jobs = $state([]); 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()); let cancelTarget = $state(null); let cancelling = $state(false); @@ -167,7 +173,11 @@ async function loadJobs() { jobsLoading = true; try { - const j = await apiJSON('/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(`/v1/jobs?${params.toString()}`); jobs = j.items; } catch (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).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() { try { const r = await apiJSON('/v1/modules?limit=200'); @@ -645,9 +729,26 @@ /> - + + (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} + />