Files
EvoBGP/web/src/lib/components/operations/OperationsJobsTab.svelte
T
Denozordec ab8660ac42 feat(web): enhance operations UI with new job status handling and component updates
- 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.
2026-05-20 12:14:33 +07:00

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>