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[];
|
||||
};
|
||||
Reference in New Issue
Block a user