diff --git a/web/src/lib/components/operations/OperationsDiffTab.svelte b/web/src/lib/components/operations/OperationsDiffTab.svelte
new file mode 100644
index 0000000..4f2a538
--- /dev/null
+++ b/web/src/lib/components/operations/OperationsDiffTab.svelte
@@ -0,0 +1,89 @@
+
+
+
+
+ Сравнение ревизий
+ Выберите ID двух ревизий для сравнения
+
+
+
+
+
+
+
+
+ {#if diffData}
+
+
+
+ Добавлено ({diffData.added?.length ?? 0})
+
+ {#each diffData.added ?? [] 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}
+ Нет изменений
+ {/each}
+
+
+
+
- Удалено ({diffData.removed?.length ?? 0})
+
+ {#each diffData.removed ?? [] 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}
+ Нет изменений
+ {/each}
+
+
+
+ {/if}
+
+
diff --git a/web/src/lib/components/operations/OperationsJobsTab.svelte b/web/src/lib/components/operations/OperationsJobsTab.svelte
new file mode 100644
index 0000000..4826349
--- /dev/null
+++ b/web/src/lib/components/operations/OperationsJobsTab.svelte
@@ -0,0 +1,315 @@
+
+
+
+
+
+ Задачи
+ Фоновые задачи (ingest, apply, refresh)
+
+
+
+
+
+ {#each jobs as job (job.job_id)}
+
+
+
+
+
+
+
{job.kind}
+
{job.job_id}
+
+
+
+
+ {#if job.status === 'running' || job.status === 'queued'}
+
+ {/if}
+
+
+
+
+
+
Статус
+
{job.status}
+
+
+
Создана
+
{formatDate(job.created_at)}
+
+
+
Запущена
+
{formatDate(job.started_at)}
+
+
+
Завершена
+
{formatDate(job.finished_at)}
+
+
+
+
+ {#if expandedJobIds.has(job.job_id)}
+ {@const detailedJob = jobDetailsById.get(job.job_id) ?? job}
+ {@const isDetailsLoading = jobDetailsLoading.has(job.job_id)}
+ {@const isReportLoading = jobReportsLoading.has(job.job_id)}
+ {@const jobReport = jobReportsById.get(job.job_id)}
+ {@const logEntries = getJobLogEntries(detailedJob)}
+ {@const logTotal = getJobLogTotal(detailedJob, logEntries)}
+
+
+ {#if isDetailsLoading}
+
Догружаем свежие детали задачи…
+ {/if}
+
+ {#if detailedJob.error}
+
+
error
+
{detailedJob.error}
+
+ {/if}
+
+ {#if logEntries.length > 0}
+
+
+
Журнал обработки
+
+ {logEntries.length} записей, всего {logTotal} префиксов
+
+
+
+
+ {#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
+
+
{entry.message}
+
+
+
source
+
{entry.source}
+
+
+
+
community
+
{entry.community}
+
+
+
prefixes
+
{entry.prefix_count}
+
+
+ {#if entry.sample && entry.sample.length > 0}
+
+
sample
+
+
+ {#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
+
{sampleValue}
+ {/each}
+
+
+
+ {/if}
+
+ {/each}
+
+
+
+ {/if}
+
+ {#if isReportLoading}
+
Собираем подробный отчёт по источникам и агрегации…
+ {/if}
+
+ {#if jobReport}
+
+
+
Операции по модулю
+
+ {#if jobReport.module}
+ {jobReport.module.type}
+ {jobReport.module.name}
+ {/if}
+ {#if jobReport.revisionId}
+ {jobReport.revisionId.slice(0, 8)}…
+ {/if}
+
+
+
+
+
Агрегация
+
{jobReport.aggregationTotal} префиксов
+
+
+
Домены
+
{jobReport.domains.length}
+
+
+
ASN
+
{jobReport.asn.length}
+
+
+
CDN/IP Range
+
{jobReport.cdn.length}/{jobReport.ipRanges.length}
+
+
+ {#if jobReport.aggregationByKind.length > 0}
+
+
Результат агрегации по типам
+
+ {#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
+ {row.kind}: {row.prefixCount}
+ {/each}
+
+
+ {/if}
+
+
+
+
Домены: какой домен какие IP/префиксы вернул
+
+ {#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
+
+
{row.label}
+
{row.prefixCount} префиксов
+
{row.prefixes.slice(0, 3).join(', ') || '—'}
+
+ {:else}
+ Нет данных по доменам
+ {/each}
+
+
+
+
ASN: сколько префиксов получено по AS
+
+ {#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
+
+ AS{row.asn}
+ {row.prefixCount}
+
+ {:else}
+ Нет данных по ASN
+ {/each}
+
+
+
+
CDN: из каждой ссылки полученные IP/префиксы
+
+ {#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
+
+
{row.label}
+
{row.prefixCount} префиксов
+
{row.prefixes.slice(0, 3).join(', ') || '—'}
+
+ {:else}
+ Нет данных по CDN
+ {/each}
+
+
+
+
IP range: итог по статическим диапазонам
+
+ {#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
+
+
{row.label}
+
{row.prefixCount} диапазонов/префиксов
+
{row.prefixes.slice(0, 3).join(', ') || '—'}
+
+ {:else}
+ Нет данных по IP range
+ {/each}
+
+
+
+
+ {/if}
+
+
+
Meta (raw JSON)
+
+ {JSON.stringify(detailedJob.meta ?? {}, null, 2)}
+
+
+
+
+ {/if}
+
+ {:else}
+ {jobsLoading ? 'Загрузка…' : 'Нет задач'}
+ {/each}
+
+
diff --git a/web/src/lib/components/operations/OperationsQuickActions.svelte b/web/src/lib/components/operations/OperationsQuickActions.svelte
new file mode 100644
index 0000000..6242e62
--- /dev/null
+++ b/web/src/lib/components/operations/OperationsQuickActions.svelte
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
Apply all speakers
+
Применить текущую конфигурацию на всех спикерах
+
+
+
+
+
+
+
+
+
BIRD Reload
+
Перезагрузить конфигурацию BIRD на всех спикерах
+
+
+
+
+
+
+
+
+
+
+
Состояние BIRD
+ {#if birdStatus}
+
{birdHealthyShortLabel(birdStatus.healthy)}
+ {/if}
+
+
+ Локально на хосте API: birdc show protocols. Не заменяет мониторинг спикеров.
+
+ {#if birdLoading}
+
Загрузка…
+ {:else if birdStatus}
+ {#if !birdStatus.birdc_configured}
+
{birdStatus.message ?? 'birdc не настроен на API.'}
+ {:else if birdStatus.error}
+
{birdStatus.error}
+ {:else}
+
+ BGP сессий:
+ {birdStatus.bgp_established}
+ /
+ {birdStatus.bgp_sessions_total}
+ Established / всего
+
+ {/if}
+ {:else}
+
Статус не загружен
+ {/if}
+
+
+
+
+ {#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
+
+ {/if}
+
+
+
+
diff --git a/web/src/lib/components/operations/OperationsRevisionsTab.svelte b/web/src/lib/components/operations/OperationsRevisionsTab.svelte
new file mode 100644
index 0000000..5b700f5
--- /dev/null
+++ b/web/src/lib/components/operations/OperationsRevisionsTab.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+
+ История ревизий
+
+ Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами
+
+
+
+
+
+
+
+
+ ID
+ Создана
+ Префиксов
+ Хэш
+
+
+
+
+ {#each revisions as rev (rev.id)}
+
+ {rev.id.slice(0, 8)}…
+ {formatDate(rev.created_at)}
+ {rev.materialized_prefix_count}
+ {rev.content_hash.slice(0, 12)}…
+
+
+
+
+
+
+
+ {:else}
+
+
+ {revLoading ? 'Загрузка…' : 'Нет ревизий'}
+
+
+ {/each}
+
+
+
+
diff --git a/web/src/lib/components/operations/types.ts b/web/src/lib/components/operations/types.ts
new file mode 100644
index 0000000..2d22fd0
--- /dev/null
+++ b/web/src/lib/components/operations/types.ts
@@ -0,0 +1,28 @@
+import type { ModuleRow } from '$lib/api/types.js';
+
+export type JobLogEntry = {
+ kind: string;
+ source: string;
+ community: string;
+ prefix_count: number;
+ sample?: string[];
+ message: string;
+};
+
+export type ReportRow = {
+ label: string;
+ source: string;
+ prefixes: string[];
+ prefixCount: number;
+};
+
+export type JobDetailedReport = {
+ revisionId: string | null;
+ module: ModuleRow | null;
+ aggregationTotal: number;
+ aggregationByKind: Array<{ kind: string; prefixCount: number }>;
+ domains: ReportRow[];
+ asn: Array<{ asn: string; prefixCount: number }>;
+ cdn: ReportRow[];
+ ipRanges: ReportRow[];
+};
diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte
index 2ea1440..c5d2ee0 100644
--- a/web/src/routes/operations/+page.svelte
+++ b/web/src/routes/operations/+page.svelte
@@ -22,7 +22,6 @@
} from '$lib/api/types.js';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
AlertDialog,
@@ -41,15 +40,12 @@
DialogTitle,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
- import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
- } from '$lib/components/ui/table/index.js';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
+ import OperationsQuickActions from '$lib/components/operations/OperationsQuickActions.svelte';
+ 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 type { JobDetailedReport, JobLogEntry, ReportRow } from '$lib/components/operations/types.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import {
dialogBodyDocument,
@@ -61,14 +57,6 @@
} from '$lib/dialog-layout.js';
import { cn } from '$lib/utils.js';
import { toast } from 'svelte-sonner';
- import RefreshCw from '@lucide/svelte/icons/refresh-cw';
- import Play from '@lucide/svelte/icons/play';
- import Undo from '@lucide/svelte/icons/undo';
- import Eye from '@lucide/svelte/icons/eye';
- import X from '@lucide/svelte/icons/x';
- import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
- import Bird from '@lucide/svelte/icons/bird';
- import ChevronDown from '@lucide/svelte/icons/chevron-down';
// Revisions
let revisions = $state([]);
@@ -102,7 +90,6 @@
}
// Diff
- let diffDialog = $state(false);
let diffRevA = $state('');
let diffRevB = $state('');
let diffData = $state(null);
@@ -121,33 +108,6 @@
let jobReportsById = new SvelteMap();
let jobReportsLoading = new SvelteSet();
- type JobLogEntry = {
- kind: string;
- source: string;
- community: string;
- prefix_count: number;
- sample?: string[];
- message: string;
- };
-
- type ReportRow = {
- label: string;
- source: string;
- prefixes: string[];
- prefixCount: number;
- };
-
- type JobDetailedReport = {
- revisionId: string | null;
- module: ModuleRow | null;
- aggregationTotal: number;
- aggregationByKind: Array<{ kind: string; prefixCount: number }>;
- domains: ReportRow[];
- asn: Array<{ asn: string; prefixCount: number }>;
- cdn: ReportRow[];
- ipRanges: ReportRow[];
- };
-
// Global apply / bird reload
let applying = $state(false);
let reloading = $state(false);
@@ -611,490 +571,72 @@
Деплой конфигурации, управление ревизиями и задачами.
-
-
-
-
-
-
Apply all speakers
-
Применить текущую конфигурацию на всех спикерах
-
-
-
-
-
-
-
-
BIRD Reload
-
Перезагрузить конфигурацию BIRD на всех спикерах
-
-
-
-
-
-
-
-
-
-
Состояние BIRD
- {#if birdStatus}
-
{birdHealthyShortLabel(birdStatus.healthy)}
- {/if}
-
-
- Локально на хосте API: birdc show protocols. Не заменяет мониторинг
- спикеров.
-
- {#if birdLoading}
-
Загрузка…
- {:else if birdStatus}
- {#if !birdStatus.birdc_configured}
-
{birdStatus.message ?? 'birdc не настроен на API.'}
- {:else if birdStatus.error}
-
{birdStatus.error}
- {:else}
-
- BGP сессий:
- {birdStatus.bgp_established}
- /
- {birdStatus.bgp_sessions_total}
- Established / всего
-
- {/if}
- {:else}
-
Статус не загружен
- {/if}
-
-
-
- {#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
-
- {/if}
-
-
-
-
+ (applyConfirm = true)}
+ onReload={() => (reloadConfirm = true)}
+ onRefreshBirdStatus={loadBirdStatus}
+ onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
+ {birdHealthyBadgeVariant}
+ {birdHealthyShortLabel}
+ />
-
+
+
Ревизии
Diff
Задачи
-
+
+
-
-
-
-
- История ревизий
- Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами
-
-
-
-
-
-
-
- ID
- Создана
- Префиксов
- Хэш
-
-
-
-
- {#each revisions as rev (rev.id)}
-
- {rev.id.slice(0, 8)}…
- {formatDate(rev.created_at)}
- {rev.materialized_prefix_count}
- {rev.content_hash.slice(0, 12)}…
-
-
-
-
-
-
-
- {:else}
-
-
- {revLoading ? 'Загрузка…' : 'Нет ревизий'}
-
-
- {/each}
-
-
-
-
+ (rollbackTarget = rev)}
+ {formatDate}
+ />
-
-
-
- Сравнение ревизий
- Выберите ID двух ревизий для сравнения
-
-
-
-
-
-
-
- {#if diffData}
-
-
-
+ Добавлено ({diffData.added?.length ?? 0})
-
- {#each diffData.added ?? [] 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}
- Нет изменений
- {/each}
-
-
-
-
- Удалено ({diffData.removed?.length ?? 0})
-
- {#each diffData.removed ?? [] 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}
- Нет изменений
- {/each}
-
-
-
- {/if}
-
-
+ (diffRevA = value)}
+ onDiffRevBChange={(value) => (diffRevB = value)}
+ onLoadDiff={loadDiff}
+ {formatDate}
+ />
-
-
-
-
- Задачи
- Фоновые задачи (ingest, apply, refresh)
-
-
-
-
-
-
-
- Вид
- Статус
- Создана
- Завершена
-
-
-
-
- {#each jobs as job (job.job_id)}
-
-
-
-
- {job.kind}
-
-
- {job.status}
- {formatDate(job.created_at)}
- {formatDate(job.finished_at)}
-
-
-
- {#if job.status === 'running' || job.status === 'queued'}
-
- {/if}
-
-
-
- {#if isJobExpanded(job.job_id)}
-
-
- {@const detailedJob = jobDetailsById.get(job.job_id) ?? job}
- {@const isDetailsLoading = jobDetailsLoading.has(job.job_id)}
- {@const isReportLoading = jobReportsLoading.has(job.job_id)}
- {@const jobReport = jobReportsById.get(job.job_id)}
- {@const logEntries = getJobLogEntries(detailedJob)}
- {@const logTotal = getJobLogTotal(detailedJob, logEntries)}
-
- {#if isDetailsLoading}
-
Догружаем свежие детали задачи…
- {/if}
-
-
-
job_id
-
{detailedJob.job_id}
-
-
-
status
-
{detailedJob.status}
-
-
-
created
-
{formatDate(detailedJob.created_at)}
-
-
-
started
-
{formatDate(detailedJob.started_at)}
-
-
-
finished
-
{formatDate(detailedJob.finished_at)}
-
-
- {#if detailedJob.error}
-
-
error
-
{detailedJob.error}
-
- {/if}
- {#if logEntries.length > 0}
-
-
-
Журнал обработки
-
- {logEntries.length} записей, всего {logTotal} префиксов
-
-
-
-
- {#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
-
-
{entry.message}
-
-
- source
- {entry.source}
-
-
- kind
- {entry.kind}
-
-
- community
- {entry.community}
-
-
- prefixes
- {entry.prefix_count}
-
-
- {#if entry.sample && entry.sample.length > 0}
-
-
sample
-
-
- {#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
-
{sampleValue}
- {/each}
-
-
-
- {/if}
-
- {/each}
-
-
-
- {/if}
- {#if isReportLoading}
-
Собираем подробный отчёт по источникам и агрегации…
- {/if}
- {#if jobReport}
-
-
-
Операции по модулю
-
- {#if jobReport.module}
- {jobReport.module.type}
- {jobReport.module.name}
- {/if}
- {#if jobReport.revisionId}
- {jobReport.revisionId.slice(0, 8)}…
- {/if}
-
-
-
-
-
Агрегация
-
{jobReport.aggregationTotal} префиксов
-
-
-
Домены
-
{jobReport.domains.length}
-
-
-
ASN
-
{jobReport.asn.length}
-
-
-
CDN/IP Range
-
{jobReport.cdn.length}/{jobReport.ipRanges.length}
-
-
- {#if jobReport.aggregationByKind.length > 0}
-
-
Результат агрегации по типам
-
- {#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
- {row.kind}: {row.prefixCount}
- {/each}
-
-
- {/if}
-
-
-
Домены: какой домен какие IP/префиксы вернул
-
- {#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
-
-
{row.label}
-
{row.prefixCount} префиксов
-
{row.prefixes.slice(0, 3).join(', ') || '—'}
-
- {:else}
- Нет данных по доменам
- {/each}
-
-
-
-
ASN: сколько префиксов получено по AS
-
- {#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
-
- AS{row.asn}
- {row.prefixCount}
-
- {:else}
- Нет данных по ASN
- {/each}
-
-
-
-
CDN: из каждой ссылки полученные IP/префиксы
-
- {#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
-
-
{row.label}
-
{row.prefixCount} префиксов
-
{row.prefixes.slice(0, 3).join(', ') || '—'}
-
- {:else}
- Нет данных по CDN
- {/each}
-
-
-
-
IP range: итог по статическим диапазонам
-
- {#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
-
-
{row.label}
-
{row.prefixCount} диапазонов/префиксов
-
{row.prefixes.slice(0, 3).join(', ') || '—'}
-
- {:else}
- Нет данных по IP range
- {/each}
-
-
-
-
- {/if}
-
-
Meta (raw JSON)
-
- {JSON.stringify(detailedJob.meta ?? {}, null, 2)}
-
-
-
-
-
- {/if}
- {:else}
-
-
- {jobsLoading ? 'Загрузка…' : 'Нет задач'}
-
-
- {/each}
-
-
-
-
+ (cancelTarget = job)}
+ onToggleJobExpanded={toggleJobExpanded}
+ {isJobExpanded}
+ {getJobLogEntries}
+ {getJobLogTotal}
+ {jobStatusVariant}
+ {formatDate}
+ />