feat: refactor operations page to enhance UI and component structure. Remove unused card components and integrate OperationsQuickActions for improved quick action handling. Update tab layout for better responsiveness and user experience.
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 1m2s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
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 1m2s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
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:
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { RevisionDiff, RevisionRow } from '$lib/api/types.js';
|
||||
import { Button } from '$lib/components/ui/button/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';
|
||||
|
||||
type Props = {
|
||||
revisions: RevisionRow[];
|
||||
diffRevA: string;
|
||||
diffRevB: string;
|
||||
diffData: RevisionDiff | null;
|
||||
diffLoading: boolean;
|
||||
onDiffRevAChange: (value: string) => void;
|
||||
onDiffRevBChange: (value: string) => void;
|
||||
onLoadDiff: () => void;
|
||||
formatDate: (d?: string | null) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
revisions,
|
||||
diffRevA,
|
||||
diffRevB,
|
||||
diffData,
|
||||
diffLoading,
|
||||
onDiffRevAChange,
|
||||
onDiffRevBChange,
|
||||
onLoadDiff,
|
||||
formatDate
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Сравнение ревизий</CardTitle>
|
||||
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<select
|
||||
value={diffRevA}
|
||||
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
||||
class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">Ревизия A</option>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
value={diffRevB}
|
||||
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
||||
class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">Ревизия B</option>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={onLoadDiff} disabled={diffLoading}>
|
||||
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if diffData}
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-green-600">+ Добавлено ({diffData.added?.length ?? 0})</p>
|
||||
<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'}`)}
|
||||
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-red-600">- Удалено ({diffData.removed?.length ?? 0})</p>
|
||||
<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'}`)}
|
||||
<p class="font-mono text-xs break-all">{typeof p === 'string' ? p : (p as { prefix?: string }).prefix ?? JSON.stringify(p)}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import type { JobRow } from '$lib/api/types.js';
|
||||
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
||||
import { Badge } from '$lib/components/ui/badge/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 { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
type Props = {
|
||||
jobs: JobRow[];
|
||||
jobsLoading: boolean;
|
||||
expandedJobIds: SvelteSet<string>;
|
||||
jobDetailsById: SvelteMap<string, JobRow>;
|
||||
jobDetailsLoading: SvelteSet<string>;
|
||||
jobReportsById: SvelteMap<string, JobDetailedReport>;
|
||||
jobReportsLoading: SvelteSet<string>;
|
||||
onReloadJobs: () => void;
|
||||
onOpenJobDetail: (job: JobRow) => void;
|
||||
onRequestCancelJob: (job: JobRow) => void;
|
||||
onToggleJobExpanded: (job: JobRow) => void | Promise<void>;
|
||||
isJobExpanded: (jobId: string) => boolean;
|
||||
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
||||
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
||||
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
formatDate: (d?: string | null) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
jobs,
|
||||
jobsLoading,
|
||||
expandedJobIds,
|
||||
jobDetailsById,
|
||||
jobDetailsLoading,
|
||||
jobReportsById,
|
||||
jobReportsLoading,
|
||||
onReloadJobs,
|
||||
onOpenJobDetail,
|
||||
onRequestCancelJob,
|
||||
onToggleJobExpanded,
|
||||
isJobExpanded,
|
||||
getJobLogEntries,
|
||||
getJobLogTotal,
|
||||
jobStatusVariant,
|
||||
formatDate
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<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">
|
||||
<CardTitle class="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={onReloadJobs} disabled={jobsLoading}>
|
||||
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="space-y-3 p-3 sm:p-4">
|
||||
{#each jobs as job (job.job_id)}
|
||||
<div class="bg-card min-w-0 rounded-lg border">
|
||||
<div class="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
|
||||
<div class="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground hover:text-foreground mt-0.5 shrink-0"
|
||||
aria-label={isJobExpanded(job.job_id) ? 'Свернуть детали задачи' : 'Развернуть детали задачи'}
|
||||
aria-expanded={isJobExpanded(job.job_id)}
|
||||
onclick={() => void onToggleJobExpanded(job)}
|
||||
>
|
||||
<ChevronDown class={cn('size-3.5 transition-transform', isJobExpanded(job.job_id) && 'rotate-180')} />
|
||||
</Button>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium break-words">{job.kind}</p>
|
||||
<p class="text-muted-foreground font-mono text-[11px] break-all">{job.job_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onOpenJobDetail(job)} aria-label="Открыть задачу в модальном окне">
|
||||
<Eye class="size-3.5" />
|
||||
</Button>
|
||||
{#if job.status === 'running' || job.status === 'queued'}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={() => onRequestCancelJob(job)}
|
||||
aria-label="Отменить задачу"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Статус</p>
|
||||
<Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge>
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Создана</p>
|
||||
<p>{formatDate(job.created_at)}</p>
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Запущена</p>
|
||||
<p>{formatDate(job.started_at)}</p>
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/25 px-2.5 py-2">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Завершена</p>
|
||||
<p>{formatDate(job.finished_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#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)}
|
||||
<div class="border-t bg-muted/10 p-3 sm:p-4">
|
||||
<div class="space-y-3">
|
||||
{#if isDetailsLoading}
|
||||
<p class="text-muted-foreground text-xs">Догружаем свежие детали задачи…</p>
|
||||
{/if}
|
||||
|
||||
{#if detailedJob.error}
|
||||
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p class="text-muted-foreground mb-1 text-xs">error</p>
|
||||
<p class="text-destructive break-words text-sm">{detailedJob.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if logEntries.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium">Журнал обработки</p>
|
||||
<p class="text-muted-foreground rounded-md border bg-muted/50 px-2 py-0.5 text-xs">
|
||||
{logEntries.length} записей, всего {logTotal} префиксов
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea class="max-h-[24rem] rounded-md border bg-muted/10 p-2">
|
||||
<div class="space-y-2">
|
||||
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
|
||||
<div class="bg-card space-y-3 rounded-lg border border-border/70 p-3 shadow-sm">
|
||||
<p class="text-sm leading-relaxed font-medium break-words">{entry.message}</p>
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">source</span>
|
||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.source}</p>
|
||||
</div>
|
||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">kind</span>
|
||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.kind}</p>
|
||||
</div>
|
||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">community</span>
|
||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs break-all">{entry.community}</p>
|
||||
</div>
|
||||
<div class="bg-muted/60 min-w-0 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">prefixes</span>
|
||||
<p class="bg-background mt-1 rounded px-1.5 py-0.5 font-mono text-xs">{entry.prefix_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if entry.sample && entry.sample.length > 0}
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase tracking-wide">sample</p>
|
||||
<ScrollArea class="max-h-28 rounded-md border bg-muted/35 p-2.5">
|
||||
<div class="space-y-1.5">
|
||||
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
|
||||
<p class="bg-background rounded px-2 py-1 font-mono text-xs break-all">{sampleValue}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isReportLoading}
|
||||
<p class="text-muted-foreground text-xs">Собираем подробный отчёт по источникам и агрегации…</p>
|
||||
{/if}
|
||||
|
||||
{#if jobReport}
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium">Операции по модулю</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if jobReport.module}
|
||||
<Badge variant="outline">{jobReport.module.type}</Badge>
|
||||
<Badge variant="secondary">{jobReport.module.name}</Badge>
|
||||
{/if}
|
||||
{#if jobReport.revisionId}
|
||||
<Badge variant="outline" class="font-mono text-[10px]">{jobReport.revisionId.slice(0, 8)}…</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Агрегация</p>
|
||||
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Домены</p>
|
||||
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">ASN</p>
|
||||
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
|
||||
</div>
|
||||
<div class="rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">CDN/IP Range</p>
|
||||
<p class="text-sm font-semibold">{jobReport.cdn.length}/{jobReport.ipRanges.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if jobReport.aggregationByKind.length > 0}
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">Результат агрегации по типам</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
|
||||
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-3 xl:grid-cols-2">
|
||||
<div class="space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">Домены: какой домен какие IP/префиксы вернул</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
|
||||
<div class="mb-2 rounded border bg-background p-2">
|
||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
|
||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по доменам</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
|
||||
<div class="mb-2 flex items-center justify-between rounded border bg-background p-2">
|
||||
<span class="font-mono text-xs">AS{row.asn}</span>
|
||||
<Badge variant="outline">{row.prefixCount}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по ASN</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">CDN: из каждой ссылки полученные IP/префиксы</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
|
||||
<div class="mb-2 rounded border bg-background p-2">
|
||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
|
||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по CDN</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">IP range: итог по статическим диапазонам</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
|
||||
<div class="mb-2 rounded border bg-background p-2">
|
||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} диапазонов/префиксов</p>
|
||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по IP range</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">Meta (raw JSON)</p>
|
||||
<ScrollArea class="max-h-56 rounded-md border bg-muted/30 p-3">
|
||||
<pre class="font-mono text-xs whitespace-pre-wrap [overflow-wrap:anywhere]">{JSON.stringify(detailedJob.meta ?? {}, null, 2)}</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground py-10 text-center text-sm">{jobsLoading ? 'Загрузка…' : 'Нет задач'}</div>
|
||||
{/each}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import type { BirdStatus } 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 } from '$lib/components/ui/card/index.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||
import Bird from '@lucide/svelte/icons/bird';
|
||||
|
||||
type Props = {
|
||||
applying: boolean;
|
||||
reloading: boolean;
|
||||
birdLoading: boolean;
|
||||
birdStatus: BirdStatus | null;
|
||||
onApply: () => void;
|
||||
onReload: () => void;
|
||||
onRefreshBirdStatus: () => void;
|
||||
onOpenBirdProtocols: () => void;
|
||||
birdHealthyBadgeVariant: (h: boolean | null | undefined) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
|
||||
};
|
||||
|
||||
let {
|
||||
applying,
|
||||
reloading,
|
||||
birdLoading,
|
||||
birdStatus,
|
||||
onApply,
|
||||
onReload,
|
||||
onRefreshBirdStatus,
|
||||
onOpenBirdProtocols,
|
||||
birdHealthyBadgeVariant,
|
||||
birdHealthyShortLabel
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
<p class="font-semibold">Apply all speakers</p>
|
||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Применить текущую конфигурацию на всех спикерах</p>
|
||||
</div>
|
||||
<Button class="w-full shrink-0 self-start sm:w-auto sm:self-auto" onclick={onApply} disabled={applying}>
|
||||
<Play />
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="min-w-0 p-4">
|
||||
<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">
|
||||
<p class="font-semibold">BIRD Reload</p>
|
||||
<p class="text-muted-foreground max-w-[42ch] text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
|
||||
onclick={onReload}
|
||||
disabled={reloading}
|
||||
>
|
||||
<RotateCcw />
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="min-w-0 p-4 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="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Bird class="text-muted-foreground size-4 shrink-0" />
|
||||
<p class="font-semibold">Состояние BIRD</p>
|
||||
{#if birdStatus}
|
||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-muted-foreground max-w-[56ch] text-sm">
|
||||
Локально на хосте API: <code class="bg-muted rounded px-1 text-xs">birdc show protocols</code>. Не заменяет мониторинг спикеров.
|
||||
</p>
|
||||
{#if birdLoading}
|
||||
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
||||
{:else if birdStatus}
|
||||
{#if !birdStatus.birdc_configured}
|
||||
<p class="text-muted-foreground text-sm">{birdStatus.message ?? 'birdc не настроен на API.'}</p>
|
||||
{:else if birdStatus.error}
|
||||
<p class="text-destructive text-sm">{birdStatus.error}</p>
|
||||
{:else}
|
||||
<p class="text-sm">
|
||||
<span class="text-muted-foreground">BGP сессий:</span>
|
||||
<span class="font-medium">{birdStatus.bgp_established}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="font-medium">{birdStatus.bgp_sessions_total}</span>
|
||||
<span class="text-muted-foreground"> Established / всего</span>
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">Статус не загружен</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex w-full shrink-0 flex-wrap gap-2 sm:w-auto sm:flex-col sm:items-stretch">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto"
|
||||
onclick={onRefreshBirdStatus}
|
||||
disabled={birdLoading}
|
||||
>
|
||||
<RefreshCw class={birdLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
||||
Обновить
|
||||
</Button>
|
||||
{#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
|
||||
<Button variant="ghost" size="sm" class="w-full sm:w-auto" onclick={onOpenBirdProtocols}>
|
||||
Вывод birdc
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import type { RevisionRow } from '$lib/api/types.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table/index.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import Undo from '@lucide/svelte/icons/undo';
|
||||
import Eye from '@lucide/svelte/icons/eye';
|
||||
|
||||
type Props = {
|
||||
revisions: RevisionRow[];
|
||||
revLoading: boolean;
|
||||
onReload: () => void;
|
||||
onOpenPreview: (rev: RevisionRow) => void;
|
||||
onRollbackRequest: (rev: RevisionRow) => void;
|
||||
formatDate: (d?: string | null) => string;
|
||||
};
|
||||
|
||||
let { revisions, revLoading, onReload, onOpenPreview, onRollbackRequest, formatDate }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<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">
|
||||
<CardTitle class="text-base">История ревизий</CardTitle>
|
||||
<CardDescription class="max-w-[75ch]">
|
||||
Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={onReload} disabled={revLoading}>
|
||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead>Хэш</TableHead>
|
||||
<TableHead class="w-32"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
||||
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
||||
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">{rev.content_hash.slice(0, 12)}…</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
||||
<Eye class="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||
<Undo class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
|
||||
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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<RevisionRow[]>([]);
|
||||
@@ -102,7 +90,6 @@
|
||||
}
|
||||
|
||||
// Diff
|
||||
let diffDialog = $state(false);
|
||||
let diffRevA = $state<string>('');
|
||||
let diffRevB = $state<string>('');
|
||||
let diffData = $state<RevisionDiff | null>(null);
|
||||
@@ -121,33 +108,6 @@
|
||||
let jobReportsById = new SvelteMap<string, JobDetailedReport>();
|
||||
let jobReportsLoading = new SvelteSet<string>();
|
||||
|
||||
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 @@
|
||||
<p class="text-muted-foreground mt-1 text-sm">Деплой конфигурации, управление ревизиями и задачами.</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions + локальный статус BIRD (хост API, если задан EVOBGP_BIRDC_SOCKET) -->
|
||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Card class="p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold">Apply all speakers</p>
|
||||
<p class="text-muted-foreground text-sm">Применить текущую конфигурацию на всех спикерах</p>
|
||||
</div>
|
||||
<Button class="shrink-0 self-start sm:self-auto" onclick={() => (applyConfirm = true)} disabled={applying}>
|
||||
<Play />
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold">BIRD Reload</p>
|
||||
<p class="text-muted-foreground text-sm">Перезагрузить конфигурацию BIRD на всех спикерах</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="shrink-0 self-start sm:self-auto"
|
||||
onclick={() => (reloadConfirm = true)}
|
||||
disabled={reloading}
|
||||
>
|
||||
<RotateCcw />
|
||||
Reload
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 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="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Bird class="text-muted-foreground size-4 shrink-0" />
|
||||
<p class="font-semibold">Состояние BIRD</p>
|
||||
{#if birdStatus}
|
||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}>{birdHealthyShortLabel(birdStatus.healthy)}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Локально на хосте API: <code class="bg-muted rounded px-1 text-xs">birdc show protocols</code>. Не заменяет мониторинг
|
||||
спикеров.
|
||||
</p>
|
||||
{#if birdLoading}
|
||||
<p class="text-muted-foreground text-sm">Загрузка…</p>
|
||||
{:else if birdStatus}
|
||||
{#if !birdStatus.birdc_configured}
|
||||
<p class="text-muted-foreground text-sm">{birdStatus.message ?? 'birdc не настроен на API.'}</p>
|
||||
{:else if birdStatus.error}
|
||||
<p class="text-destructive text-sm">{birdStatus.error}</p>
|
||||
{:else}
|
||||
<p class="text-sm">
|
||||
<span class="text-muted-foreground">BGP сессий:</span>
|
||||
<span class="font-medium">{birdStatus.bgp_established}</span>
|
||||
<span class="text-muted-foreground">/</span>
|
||||
<span class="font-medium">{birdStatus.bgp_sessions_total}</span>
|
||||
<span class="text-muted-foreground"> Established / всего</span>
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-sm">Статус не загружен</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap gap-2 sm:flex-col sm:items-stretch">
|
||||
<Button variant="outline" size="sm" onclick={loadBirdStatus} disabled={birdLoading}>
|
||||
<RefreshCw class={birdLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
|
||||
Обновить
|
||||
</Button>
|
||||
{#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
|
||||
<Button variant="ghost" size="sm" onclick={() => (birdProtocolsOpen = true)}>Вывод birdc</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<OperationsQuickActions
|
||||
{applying}
|
||||
{reloading}
|
||||
{birdLoading}
|
||||
{birdStatus}
|
||||
onApply={() => (applyConfirm = true)}
|
||||
onReload={() => (reloadConfirm = true)}
|
||||
onRefreshBirdStatus={loadBirdStatus}
|
||||
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
|
||||
{birdHealthyBadgeVariant}
|
||||
{birdHealthyShortLabel}
|
||||
/>
|
||||
|
||||
<Tabs value="revisions">
|
||||
<TabsList>
|
||||
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="diff">Diff</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Задачи</TabsTrigger>
|
||||
</TabsList>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<!-- Revisions -->
|
||||
<TabsContent value="revisions" class="mt-4">
|
||||
<Card>
|
||||
<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">
|
||||
<CardTitle class="text-base">История ревизий</CardTitle>
|
||||
<CardDescription>Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={loadRevisions} disabled={revLoading}>
|
||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Префиксов</TableHead>
|
||||
<TableHead>Хэш</TableHead>
|
||||
<TableHead class="w-32"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
||||
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
||||
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
||||
<TableCell class="font-mono text-xs text-muted-foreground">{rev.content_hash.slice(0, 12)}…</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-1">
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => openPreview(rev)}>
|
||||
<Eye class="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => (rollbackTarget = rev)}>
|
||||
<Undo class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-muted-foreground text-center py-8">
|
||||
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OperationsRevisionsTab
|
||||
{revisions}
|
||||
{revLoading}
|
||||
onReload={loadRevisions}
|
||||
onOpenPreview={openPreview}
|
||||
onRollbackRequest={(rev) => (rollbackTarget = rev)}
|
||||
{formatDate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Diff -->
|
||||
<TabsContent value="diff" class="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Сравнение ревизий</CardTitle>
|
||||
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<select bind:value={diffRevA} class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm">
|
||||
<option value="">Ревизия A</option>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={diffRevB} class="border-border bg-background h-8 min-w-0 flex-1 rounded-md border px-2 text-sm">
|
||||
<option value="">Ревизия B</option>
|
||||
{#each revisions as rev (rev.id)}
|
||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button size="sm" class="shrink-0 self-start sm:self-auto" onclick={loadDiff} disabled={diffLoading}>
|
||||
{diffLoading ? 'Загрузка…' : 'Сравнить'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if diffData}
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-600 mb-2">+ Добавлено ({diffData.added?.length ?? 0})</p>
|
||||
<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'}`)}
|
||||
<p class="font-mono text-xs">{typeof p === 'string' ? p : (p as {prefix?: string}).prefix ?? JSON.stringify(p)}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-red-600 mb-2">- Удалено ({diffData.removed?.length ?? 0})</p>
|
||||
<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'}`)}
|
||||
<p class="font-mono text-xs">{typeof p === 'string' ? p : (p as {prefix?: string}).prefix ?? JSON.stringify(p)}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет изменений</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OperationsDiffTab
|
||||
{revisions}
|
||||
{diffRevA}
|
||||
{diffRevB}
|
||||
{diffData}
|
||||
{diffLoading}
|
||||
onDiffRevAChange={(value) => (diffRevA = value)}
|
||||
onDiffRevBChange={(value) => (diffRevB = value)}
|
||||
onLoadDiff={loadDiff}
|
||||
{formatDate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Jobs -->
|
||||
<TabsContent value="jobs" class="mt-4">
|
||||
<Card>
|
||||
<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">
|
||||
<CardTitle class="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Фоновые задачи (ingest, apply, refresh)</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="shrink-0 self-start sm:self-auto" onclick={loadJobs} disabled={jobsLoading}>
|
||||
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Завершена</TableHead>
|
||||
<TableHead class="w-32"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each jobs as job (job.job_id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
aria-label={isJobExpanded(job.job_id) ? 'Свернуть детали задачи' : 'Развернуть детали задачи'}
|
||||
aria-expanded={isJobExpanded(job.job_id)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleJobExpanded(job);
|
||||
}}
|
||||
>
|
||||
<ChevronDown class={cn('size-3.5 transition-transform', isJobExpanded(job.job_id) && 'rotate-180')} />
|
||||
</Button>
|
||||
<span>{job.kind}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant={jobStatusVariant(job.status)}>{job.status}</Badge></TableCell>
|
||||
<TableCell class="text-sm">{formatDate(job.created_at)}</TableCell>
|
||||
<TableCell class="text-sm">{formatDate(job.finished_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
openJobDetail(job);
|
||||
}}
|
||||
aria-label="Открыть задачу в модальном окне"
|
||||
>
|
||||
<Eye class="size-3.5" />
|
||||
</Button>
|
||||
{#if job.status === 'running' || job.status === 'queued'}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelTarget = job;
|
||||
}}
|
||||
aria-label="Отменить задачу"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#if isJobExpanded(job.job_id)}
|
||||
<TableRow class="bg-muted/20 hover:bg-muted/30">
|
||||
<TableCell colspan={5} class="whitespace-normal align-top p-0">
|
||||
{@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)}
|
||||
<div class="min-w-0 max-w-full overflow-hidden space-y-3 py-3 px-2 sm:px-3">
|
||||
{#if isDetailsLoading}
|
||||
<p class="text-muted-foreground text-xs">Догружаем свежие детали задачи…</p>
|
||||
{/if}
|
||||
<div class="grid min-w-0 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<p class="text-muted-foreground text-xs">job_id</p>
|
||||
<p class="font-mono break-all text-xs">{detailedJob.job_id}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">status</p>
|
||||
<Badge variant={jobStatusVariant(detailedJob.status)}>{detailedJob.status}</Badge>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">created</p>
|
||||
<p>{formatDate(detailedJob.created_at)}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">started</p>
|
||||
<p>{formatDate(detailedJob.started_at)}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">finished</p>
|
||||
<p>{formatDate(detailedJob.finished_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if detailedJob.error}
|
||||
<div class="min-w-0 overflow-hidden rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<p class="text-muted-foreground mb-1 text-xs">error</p>
|
||||
<p class="text-destructive break-words text-sm">{detailedJob.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if logEntries.length > 0}
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium">Журнал обработки</p>
|
||||
<p class="text-muted-foreground rounded-md border bg-muted/50 px-2 py-0.5 text-xs">
|
||||
{logEntries.length} записей, всего {logTotal} префиксов
|
||||
</p>
|
||||
</div>
|
||||
<ScrollArea class="max-h-[28rem] rounded-md border bg-muted/10 p-2">
|
||||
<div class="space-y-2">
|
||||
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
|
||||
<div class="bg-card/90 min-w-0 space-y-3 rounded-lg border border-border/70 p-3 shadow-sm">
|
||||
<p class="text-sm font-medium break-words leading-relaxed">{entry.message}</p>
|
||||
<div class="grid min-w-0 gap-2 md:grid-cols-2">
|
||||
<div class="bg-muted/60 min-w-0 flex flex-col gap-1 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">source</span>
|
||||
<span class="bg-background min-w-0 overflow-hidden font-mono rounded px-1.5 py-0.5 text-xs break-all">{entry.source}</span>
|
||||
</div>
|
||||
<div class="bg-muted/60 min-w-0 flex flex-col gap-1 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">kind</span>
|
||||
<span class="bg-background min-w-0 overflow-hidden font-mono rounded px-1.5 py-0.5 text-xs break-all">{entry.kind}</span>
|
||||
</div>
|
||||
<div class="bg-muted/60 min-w-0 flex flex-col gap-1 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">community</span>
|
||||
<span class="bg-background min-w-0 overflow-hidden font-mono rounded px-1.5 py-0.5 text-xs break-all">{entry.community}</span>
|
||||
</div>
|
||||
<div class="bg-muted/60 min-w-0 flex flex-col gap-1 rounded-md border px-2.5 py-2">
|
||||
<span class="text-muted-foreground text-[11px] uppercase tracking-wide">prefixes</span>
|
||||
<span class="bg-background min-w-0 overflow-hidden font-mono rounded px-1.5 py-0.5 text-xs break-all">{entry.prefix_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if entry.sample && entry.sample.length > 0}
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase tracking-wide">sample</p>
|
||||
<ScrollArea class="max-h-28 rounded-md border bg-muted/35 p-2.5">
|
||||
<div class="space-y-1.5">
|
||||
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
|
||||
<p class="bg-background font-mono rounded px-2 py-1 text-xs break-all">{sampleValue}</p>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isReportLoading}
|
||||
<p class="text-muted-foreground text-xs">Собираем подробный отчёт по источникам и агрегации…</p>
|
||||
{/if}
|
||||
{#if jobReport}
|
||||
<div class="min-w-0 overflow-hidden space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium">Операции по модулю</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if jobReport.module}
|
||||
<Badge variant="outline">{jobReport.module.type}</Badge>
|
||||
<Badge variant="secondary">{jobReport.module.name}</Badge>
|
||||
{/if}
|
||||
{#if jobReport.revisionId}
|
||||
<Badge variant="outline" class="font-mono text-[10px]">{jobReport.revisionId.slice(0, 8)}…</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Агрегация</p>
|
||||
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">Домены</p>
|
||||
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">ASN</p>
|
||||
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden rounded-md border bg-muted/40 p-2.5">
|
||||
<p class="text-muted-foreground text-[11px] uppercase">CDN/IP Range</p>
|
||||
<p class="text-sm font-semibold">{jobReport.cdn.length}/{jobReport.ipRanges.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if jobReport.aggregationByKind.length > 0}
|
||||
<div class="space-y-1">
|
||||
<p class="text-muted-foreground text-xs">Результат агрегации по типам</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
|
||||
<Badge variant="outline">{row.kind}: {row.prefixCount}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid min-w-0 gap-3 xl:grid-cols-2">
|
||||
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">Домены: какой домен какие IP/префиксы вернул</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.domains as row (`${job.job_id}-domain-${row.source}`)}
|
||||
<div class="mb-2 rounded border bg-background p-2">
|
||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
|
||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по доменам</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.asn as row (`${job.job_id}-asn-${row.asn}`)}
|
||||
<div class="mb-2 flex items-center justify-between rounded border bg-background p-2">
|
||||
<span class="font-mono text-xs">AS{row.asn}</span>
|
||||
<Badge variant="outline">{row.prefixCount}</Badge>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по ASN</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">CDN: из каждой ссылки полученные IP/префиксы</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.cdn as row (`${job.job_id}-cdn-${row.source}`)}
|
||||
<div class="mb-2 rounded border bg-background p-2">
|
||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} префиксов</p>
|
||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по CDN</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden space-y-1.5 rounded-lg border p-3">
|
||||
<p class="text-sm font-medium">IP range: итог по статическим диапазонам</p>
|
||||
<ScrollArea class="max-h-44 rounded border bg-muted/25 p-2">
|
||||
{#each jobReport.ipRanges as row (`${job.job_id}-ipr-${row.source}`)}
|
||||
<div class="mb-2 rounded border bg-background p-2">
|
||||
<p class="font-mono text-xs break-all">{row.label}</p>
|
||||
<p class="text-muted-foreground text-[11px]">{row.prefixCount} диапазонов/префиксов</p>
|
||||
<p class="font-mono text-[11px] break-all">{row.prefixes.slice(0, 3).join(', ') || '—'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет данных по IP range</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 overflow-hidden space-y-1">
|
||||
<p class="text-muted-foreground text-xs">Meta (raw JSON)</p>
|
||||
<ScrollArea class="max-h-56 rounded-md border bg-muted/30 p-3">
|
||||
<pre class="font-mono text-xs whitespace-pre-wrap break-all [overflow-wrap:anywhere]">{JSON.stringify(detailedJob.meta ?? {}, null, 2)}</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/if}
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-muted-foreground text-center py-8">
|
||||
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OperationsJobsTab
|
||||
{jobs}
|
||||
{jobsLoading}
|
||||
{expandedJobIds}
|
||||
{jobDetailsById}
|
||||
{jobDetailsLoading}
|
||||
{jobReportsById}
|
||||
{jobReportsLoading}
|
||||
onReloadJobs={loadJobs}
|
||||
onOpenJobDetail={openJobDetail}
|
||||
onRequestCancelJob={(job) => (cancelTarget = job)}
|
||||
onToggleJobExpanded={toggleJobExpanded}
|
||||
{isJobExpanded}
|
||||
{getJobLogEntries}
|
||||
{getJobLogTotal}
|
||||
{jobStatusVariant}
|
||||
{formatDate}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user