-
+
+
+
Состояние 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}
+ />