- Added a new function for determining job status badge variants to improve UI consistency. - Refactored imports to utilize the core UI library for better maintainability. - Updated job report and filters components to enhance user experience and streamline functionality. - Improved layout and responsiveness across operations-related components.
464 lines
18 KiB
Svelte
464 lines
18 KiB
Svelte
<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/ui/core/badge/index.js';
|
|
import { Button } from '$lib/ui/core/button/index.js';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle
|
|
} from '$lib/ui/core/card/index.js';
|
|
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
|
import { formatDateTime } from '$lib/modules/display.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 CircleDot from '@lucide/svelte/icons/circle-dot';
|
|
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
|
import PlayCircle from '@lucide/svelte/icons/play-circle';
|
|
import Flag from '@lucide/svelte/icons/flag';
|
|
import Layers from '@lucide/svelte/icons/layers';
|
|
import Globe from '@lucide/svelte/icons/globe';
|
|
import Binary from '@lucide/svelte/icons/binary';
|
|
import Link2 from '@lucide/svelte/icons/link-2';
|
|
import { cn } from '$lib/utils.js';
|
|
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
|
|
import { jobStatusRu, logKindRu, moduleTypeRu } from '$lib/ui-labels.js';
|
|
import JobReportTableBlock from './job-report-table-block.svelte';
|
|
import { asnReportColumns, reportRowColumns } from './job-report-columns.js';
|
|
import type { RowData } from '@tanstack/table-core';
|
|
|
|
type Props = {
|
|
jobs: JobRow[];
|
|
/** Сколько задач вернул API до клиентских фильтров */
|
|
jobsFetchedTotal?: number;
|
|
jobsLoading: boolean;
|
|
moduleNameById: ReadonlyMap<string, string>;
|
|
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';
|
|
};
|
|
|
|
let {
|
|
jobs,
|
|
jobsFetchedTotal,
|
|
jobsLoading,
|
|
moduleNameById,
|
|
expandedJobIds,
|
|
jobDetailsById,
|
|
jobDetailsLoading,
|
|
jobReportsById,
|
|
jobReportsLoading,
|
|
onReloadJobs,
|
|
onOpenJobDetail,
|
|
onRequestCancelJob,
|
|
onToggleJobExpanded,
|
|
isJobExpanded,
|
|
getJobLogEntries,
|
|
getJobLogTotal,
|
|
jobStatusVariant
|
|
}: Props = $props();
|
|
|
|
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
|
RowData,
|
|
unknown
|
|
>[];
|
|
const asnCols = asnReportColumns as import('@tanstack/table-core').ColumnDef<RowData, unknown>[];
|
|
</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="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span>Фоновые задачи (ingest, применение, обновление)</span>
|
|
{#if jobsFetchedTotal !== undefined}
|
|
<span class="font-normal text-muted-foreground tabular-nums">
|
|
· Показано {jobs.length} из {jobsFetchedTotal}
|
|
</span>
|
|
{/if}
|
|
</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)}
|
|
{@const kindSub = jobKindSubtitle(job, moduleNameById)}
|
|
<div class="min-w-0 overflow-hidden rounded-lg border bg-card">
|
|
<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="flex min-w-0 items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
class="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
|
|
aria-label={isJobExpanded(job.job_id)
|
|
? 'Свернуть детали задачи'
|
|
: 'Развернуть детали задачи'}
|
|
aria-expanded={isJobExpanded(job.job_id)}
|
|
onclick={() => void onToggleJobExpanded(job)}
|
|
>
|
|
<ChevronDown
|
|
class={cn(
|
|
'size-4 transition-transform',
|
|
isJobExpanded(job.job_id) && 'rotate-180'
|
|
)}
|
|
/>
|
|
</Button>
|
|
<div class="min-w-0">
|
|
<p class="font-medium break-words">{jobKindTitle(job, moduleNameById)}</p>
|
|
{#if kindSub}
|
|
<p class="font-mono text-xs break-all text-muted-foreground">{kindSub}</p>
|
|
{/if}
|
|
<p class="font-mono text-xs break-all text-muted-foreground">{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 border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
|
|
>
|
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase">
|
|
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
|
|
Статус
|
|
</p>
|
|
<Badge class="mt-1" variant={jobStatusVariant(job.status)}
|
|
>{jobStatusRu(job.status)}</Badge
|
|
>
|
|
</div>
|
|
<div
|
|
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
|
|
>
|
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase">
|
|
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
|
Создана
|
|
</p>
|
|
<p class="mt-1">{formatDateTime(job.created_at)}</p>
|
|
</div>
|
|
<div
|
|
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
|
>
|
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase">
|
|
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
|
Запущена
|
|
</p>
|
|
<p class="mt-1">{formatDateTime(job.started_at)}</p>
|
|
</div>
|
|
<div
|
|
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
|
>
|
|
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase">
|
|
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
|
Завершена
|
|
</p>
|
|
<p class="mt-1">{formatDateTime(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 pr-1">
|
|
{#if isDetailsLoading}
|
|
<p class="text-xs text-muted-foreground">Догружаем свежие детали задачи…</p>
|
|
{/if}
|
|
|
|
{#if detailedJob.error}
|
|
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
|
<p class="mb-1 text-xs text-muted-foreground">Ошибка</p>
|
|
<p class="text-sm break-words text-destructive">{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="rounded-md border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground"
|
|
>
|
|
{logEntries.length} записей, всего {logTotal} префиксов
|
|
</p>
|
|
</div>
|
|
<div class="rounded-md border bg-muted/10 p-2">
|
|
<div class="space-y-2">
|
|
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
|
|
<div
|
|
class="space-y-3 rounded-lg border border-border/70 bg-card 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="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
|
<span
|
|
class="text-[11px] tracking-wide text-muted-foreground uppercase"
|
|
>Источник</span
|
|
>
|
|
<p
|
|
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
|
|
>
|
|
{entry.source}
|
|
</p>
|
|
</div>
|
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
|
<span
|
|
class="text-[11px] tracking-wide text-muted-foreground uppercase"
|
|
>Тип</span
|
|
>
|
|
<p
|
|
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
|
|
>
|
|
{logKindRu(entry.kind)}
|
|
</p>
|
|
</div>
|
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
|
<span
|
|
class="text-[11px] tracking-wide text-muted-foreground uppercase"
|
|
>Сообщество BGP</span
|
|
>
|
|
<p
|
|
class="mt-1 rounded bg-background px-1.5 py-0.5 text-xs break-words"
|
|
title={entry.community !== 'none' ? entry.community : undefined}
|
|
>
|
|
{entry.community_label?.trim() || entry.community}
|
|
</p>
|
|
</div>
|
|
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
|
|
<span
|
|
class="text-[11px] tracking-wide text-muted-foreground uppercase"
|
|
>Префиксы</span
|
|
>
|
|
<p class="mt-1 rounded bg-background 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-[11px] tracking-wide text-muted-foreground uppercase">
|
|
Примеры
|
|
</p>
|
|
<div class="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="rounded bg-background px-2 py-1 font-mono text-xs break-all"
|
|
>
|
|
{sampleValue}
|
|
</p>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if isReportLoading}
|
|
<p class="text-xs text-muted-foreground">
|
|
Собираем подробный отчёт по источникам и агрегации…
|
|
</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">{moduleTypeRu(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 border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
|
|
>
|
|
<p
|
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase"
|
|
>
|
|
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
|
|
Агрегация
|
|
</p>
|
|
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
|
|
</div>
|
|
<div
|
|
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
|
|
>
|
|
<p
|
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase"
|
|
>
|
|
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
|
|
Домены
|
|
</p>
|
|
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
|
|
</div>
|
|
<div
|
|
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
|
|
>
|
|
<p
|
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase"
|
|
>
|
|
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
|
|
ASN
|
|
</p>
|
|
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
|
|
</div>
|
|
<div
|
|
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
|
|
>
|
|
<p
|
|
class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase"
|
|
>
|
|
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
|
|
CDN / IP-диапазоны
|
|
</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-xs text-muted-foreground">Результат агрегации по типам</p>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
|
|
<Badge variant="outline">{logKindRu(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 space-y-1.5 rounded-lg border p-3">
|
|
<p class="text-sm font-medium">
|
|
Домены: какой домен какие IP/префиксы вернул
|
|
</p>
|
|
<JobReportTableBlock
|
|
rows={jobReport.domains as RowData[]}
|
|
columns={reportCols}
|
|
emptyLabel="Нет данных по доменам"
|
|
/>
|
|
</div>
|
|
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
|
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
|
|
<JobReportTableBlock
|
|
rows={jobReport.asn as RowData[]}
|
|
columns={asnCols}
|
|
emptyLabel="Нет данных по ASN"
|
|
/>
|
|
</div>
|
|
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
|
<p class="text-sm font-medium">
|
|
CDN: из каждой ссылки полученные IP/префиксы
|
|
</p>
|
|
<JobReportTableBlock
|
|
rows={jobReport.cdn as RowData[]}
|
|
columns={reportCols}
|
|
emptyLabel="Нет данных по CDN"
|
|
/>
|
|
</div>
|
|
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
|
|
<p class="text-sm font-medium">
|
|
IP-диапазоны: итог по статическим диапазонам
|
|
</p>
|
|
<JobReportTableBlock
|
|
rows={jobReport.ipRanges as RowData[]}
|
|
columns={reportCols}
|
|
emptyLabel="Нет данных по IP range"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="space-y-1">
|
|
<p class="text-xs text-muted-foreground">Meta (JSON)</p>
|
|
<div class="rounded-md border bg-muted/30 p-3">
|
|
<pre
|
|
class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify(
|
|
detailedJob.meta ?? {},
|
|
null,
|
|
2
|
|
)}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
{#if jobsLoading}
|
|
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
|
|
{:else}
|
|
<EmptyState
|
|
title="Нет задач"
|
|
description="Задачи появятся после refresh, apply или rollback."
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
</CardContent>
|
|
</Card>
|