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}

+
+
+ 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} +
+
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} + />