feat(web): refactor module display logic and enhance UI components
- Introduced new utility functions for date formatting and module interval labeling. - Replaced inline badge variant logic with a dedicated function for improved readability. - Updated imports to streamline component usage and enhance maintainability. - Enhanced the overall structure of the module display for better clarity and user experience.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import type { ModuleRow } from '$lib/api/types.js';
|
||||
|
||||
/** Форматирование ISO-даты для таблиц модулей и расписания. */
|
||||
export function formatDateTime(value: string | null | undefined): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '—';
|
||||
return parsed.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
/** Подпись интервала refresh: секунды, cron или комбинация. */
|
||||
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
|
||||
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
|
||||
const raw = moduleRow.refresh_interval_sec as unknown;
|
||||
const interval =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && raw.trim().length > 0
|
||||
? Number(raw)
|
||||
: null;
|
||||
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
|
||||
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
|
||||
if (cron) return cron;
|
||||
if (intervalLabel) return intervalLabel;
|
||||
return '—';
|
||||
}
|
||||
|
||||
export function moduleTypeBadgeVariant(
|
||||
type: string
|
||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
return 'default';
|
||||
case 'CDN_CIDRS':
|
||||
return 'secondary';
|
||||
case 'DOMAINS':
|
||||
return 'outline';
|
||||
case 'IP_RANGES':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||
import {
|
||||
formatDateTime,
|
||||
moduleIntervalLabel,
|
||||
moduleTypeBadgeVariant
|
||||
} from '$lib/modules/display.js';
|
||||
import Boxes from '@lucide/svelte/icons/boxes';
|
||||
|
||||
let rows = $state<ModuleRow[]>([]);
|
||||
@@ -116,44 +121,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function typeBadgeVariant(type: string) {
|
||||
switch (type) {
|
||||
case 'AS_PREFIXES':
|
||||
return 'default';
|
||||
case 'CDN_CIDRS':
|
||||
return 'secondary';
|
||||
case 'DOMAINS':
|
||||
return 'outline';
|
||||
case 'IP_RANGES':
|
||||
return 'outline';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
function moduleIntervalLabel(moduleRow: ModuleRow): string {
|
||||
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
|
||||
const raw = moduleRow.refresh_interval_sec as unknown;
|
||||
const interval =
|
||||
typeof raw === 'number'
|
||||
? raw
|
||||
: typeof raw === 'string' && raw.trim().length > 0
|
||||
? Number(raw)
|
||||
: null;
|
||||
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
|
||||
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
|
||||
if (cron) return cron;
|
||||
if (intervalLabel) return intervalLabel;
|
||||
return '—';
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return '—';
|
||||
return parsed.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function toggleModuleSelection(id: string) {
|
||||
const next = new Set(selectedModuleIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -255,7 +222,7 @@
|
||||
{:else if column.id === 'name'}
|
||||
<span class="font-medium">{m.name}</span>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'priority'}
|
||||
<span class="text-muted-foreground">{m.priority}</span>
|
||||
{:else if column.id === 'interval'}
|
||||
|
||||
@@ -1,258 +1,406 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { apiFetch, apiJSON } from '$lib/api/client.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { JobRow, JobsResponse, ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import {
|
||||
formatDateTime,
|
||||
moduleIntervalLabel,
|
||||
moduleTypeBadgeVariant
|
||||
} from '$lib/modules/display.js';
|
||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||
import { jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/components/ui/card/index.js';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
|
||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||
import ListTodo from '@lucide/svelte/icons/list-todo';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
type JobsTab = 'all' | 'refresh' | 'failed';
|
||||
|
||||
let modules = $state<ModuleRow[]>([]);
|
||||
let jobs = $state<JobRow[]>([]);
|
||||
let refreshing = $state<Record<string, boolean>>({});
|
||||
let loading = $state(false);
|
||||
let moduleRefreshing = $state<Record<string, boolean>>({});
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
let initialLoading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let jobsTab = $state<JobsTab>('all');
|
||||
|
||||
const statAccents = [
|
||||
{
|
||||
border: 'border-l-chart-1',
|
||||
bg: 'bg-chart-1/5',
|
||||
iconBg: 'bg-chart-1/15',
|
||||
iconText: 'text-chart-1'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-2',
|
||||
bg: 'bg-chart-2/5',
|
||||
iconBg: 'bg-chart-2/15',
|
||||
iconText: 'text-chart-2'
|
||||
},
|
||||
{
|
||||
border: 'border-l-chart-4',
|
||||
bg: 'bg-chart-4/5',
|
||||
iconBg: 'bg-chart-4/15',
|
||||
iconText: 'text-chart-4'
|
||||
}
|
||||
] as const;
|
||||
|
||||
const moduleColumns = [
|
||||
{ id: 'name', label: 'Модуль', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||
{ id: 'type', label: 'Тип', sortable: true, sortValue: (m: ModuleRow) => m.type },
|
||||
{ id: 'schedule', label: 'Расписание' },
|
||||
{
|
||||
id: 'refreshed',
|
||||
label: 'Последнее обновление',
|
||||
sortable: true,
|
||||
sortValue: (m: ModuleRow) => m.last_refreshed_at ?? ''
|
||||
},
|
||||
{ id: 'status', label: 'Статус' },
|
||||
{ id: 'actions', label: '', class: 'w-32 text-right' }
|
||||
] as const;
|
||||
|
||||
const jobColumns = [
|
||||
{ id: 'kind', label: 'Вид' },
|
||||
{ id: 'status', label: 'Статус', sortable: true, sortValue: (j: JobRow) => j.status },
|
||||
{
|
||||
id: 'created',
|
||||
label: 'Создана',
|
||||
sortable: true,
|
||||
sortValue: (j: JobRow) => j.created_at ?? ''
|
||||
},
|
||||
{
|
||||
id: 'finished',
|
||||
label: 'Завершена',
|
||||
sortable: true,
|
||||
sortValue: (j: JobRow) => j.finished_at ?? ''
|
||||
},
|
||||
{ id: 'error', label: 'Ошибка', class: 'max-w-xs' }
|
||||
] as const;
|
||||
|
||||
const moduleNameById = $derived(new Map(modules.map((m) => [m.id, m.name])));
|
||||
|
||||
const runningJobsCount = $derived(
|
||||
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
);
|
||||
|
||||
const failedJobsCount = $derived(
|
||||
jobs.filter((j) => {
|
||||
const s = String(j.status ?? '').toLowerCase();
|
||||
return s === 'failed' || s === 'error' || s === 'canceled';
|
||||
}).length
|
||||
);
|
||||
|
||||
const filteredJobs = $derived.by(() => {
|
||||
if (jobsTab === 'refresh') return jobs.filter((j) => j.kind === 'module_refresh');
|
||||
if (jobsTab === 'failed') {
|
||||
return jobs.filter((j) => {
|
||||
const s = String(j.status ?? '').toLowerCase();
|
||||
return s === 'failed' || s === 'error' || s === 'canceled';
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
});
|
||||
|
||||
const kpiCards = $derived.by(() => [
|
||||
{
|
||||
id: 'total',
|
||||
label: 'Всего задач',
|
||||
value: initialLoading ? '—' : String(jobs.length),
|
||||
description: 'в последней выборке',
|
||||
icon: ListTodo,
|
||||
accent: statAccents[0],
|
||||
badge: 'в выборке',
|
||||
href: '/operations' as const
|
||||
},
|
||||
{
|
||||
id: 'running',
|
||||
label: 'В работе',
|
||||
value: initialLoading ? '—' : String(runningJobsCount),
|
||||
description: 'queued и running',
|
||||
icon: Clock,
|
||||
accent: statAccents[1],
|
||||
badge: 'активных',
|
||||
href: undefined
|
||||
},
|
||||
{
|
||||
id: 'failed',
|
||||
label: 'С ошибкой',
|
||||
value: initialLoading ? '—' : String(failedJobsCount),
|
||||
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||
icon: AlertTriangle,
|
||||
accent: statAccents[2],
|
||||
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
|
||||
badgeClass:
|
||||
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||
href: failedJobsCount > 0 ? ('/operations' as const) : undefined
|
||||
}
|
||||
]);
|
||||
|
||||
function jobStatusVariant(s: string): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
const lower = s.toLowerCase();
|
||||
if (lower === 'succeeded') return 'default';
|
||||
if (lower === 'running' || lower === 'queued') return 'secondary';
|
||||
if (lower === 'failed' || lower === 'error' || lower === 'canceled') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function truncateError(error: string | null | undefined, max = 120): string {
|
||||
if (!error) return '';
|
||||
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
if (!initialLoading) refreshing = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const [m, j] = await Promise.all([
|
||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=50')
|
||||
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||
]);
|
||||
modules = m.items ?? [];
|
||||
jobs = j.items ?? [];
|
||||
lastUpdated = new Date();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
initialLoading = false;
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModule(id: string) {
|
||||
moduleRefreshing = { ...moduleRefreshing, [id]: true };
|
||||
try {
|
||||
const result = await apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST');
|
||||
if (result === undefined) {
|
||||
notify.message('Обновление не требуется (тип IP_RANGES)');
|
||||
} else {
|
||||
notify.success('Задача поставлена в очередь');
|
||||
await load();
|
||||
}
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
moduleRefreshing = { ...moduleRefreshing, [id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
async function refreshModule(id: string) {
|
||||
refreshing = { ...refreshing, [id]: true };
|
||||
try {
|
||||
const token = localStorage.getItem('evobgp_api_token') ?? '';
|
||||
const res = await fetch(`/v1/modules/${id}/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.status === 204) toast.message('Обновление не требуется (тип IP_RANGES)');
|
||||
else if (res.status === 202) {
|
||||
toast.success('Задача поставлена в очередь');
|
||||
await load();
|
||||
} else toast.error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
refreshing = { ...refreshing, [id]: false };
|
||||
}
|
||||
}
|
||||
|
||||
function intervalLabel(sec: number | null) {
|
||||
if (!sec) return '—';
|
||||
if (sec % 3600 === 0) return `${sec / 3600} ч`;
|
||||
if (sec % 60 === 0) return `${sec / 60} мин`;
|
||||
return `${sec} с`;
|
||||
}
|
||||
|
||||
function jobStatusVariant(s: string): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||
if (s === 'succeeded') return 'default';
|
||||
if (s === 'running') return 'secondary';
|
||||
if (s === 'failed') return 'destructive';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
const refreshJobs = $derived(jobs.filter((j) => j.kind === 'module_refresh'));
|
||||
const moduleNameById = $derived(new Map(modules.map((m) => [m.id, m.name])));
|
||||
const runningJobsCount = $derived(
|
||||
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||
);
|
||||
const failedJobsCount = $derived(jobs.filter((j) => j.status === 'failed').length);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Расписание и задачи"
|
||||
description="Интервалы обновления модулей и ручной запуск обновления."
|
||||
description={lastUpdated
|
||||
? `Интервалы обновления модулей и ручной запуск. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||
: 'Интервалы обновления модулей и ручной запуск обновления.'}
|
||||
icon={CalendarClock}
|
||||
iconClass="bg-chart-4/15 text-chart-4"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
||||
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||
Обновить
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<Card class="p-4">
|
||||
<p class="text-xs text-muted-foreground">Всего задач</p>
|
||||
<p class="text-lg font-semibold">{jobs.length}</p>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<p class="text-xs text-muted-foreground">В работе</p>
|
||||
<p class="text-lg font-semibold">{runningJobsCount}</p>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<p class="text-xs text-muted-foreground">С ошибкой</p>
|
||||
<p class="text-lg font-semibold">{failedJobsCount}</p>
|
||||
</Card>
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Как работает расписание</AlertTitle>
|
||||
<AlertDescription>
|
||||
Планировщик использует <code class="text-xs">refresh_interval_sec</code> и опционально
|
||||
<code class="text-xs">cron_expr</code>. Ручной запуск —
|
||||
<code class="text-xs">POST /v1/modules{id}/refresh</code>; для модулей
|
||||
<code class="text-xs">IP_RANGES</code>
|
||||
сервер может вернуть <strong>204</strong>
|
||||
(no-op). Полный список задач и деплой — на странице
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
{#if initialLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<CardSkeleton />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each kpiCards as card (card.id)}
|
||||
{@const Icon = card.icon}
|
||||
{@const a = card.accent}
|
||||
<Card
|
||||
class={cn(
|
||||
'overflow-hidden border-l-4 shadow-sm transition-colors',
|
||||
a.border,
|
||||
a.bg,
|
||||
card.href ? 'hover:border-primary/35' : ''
|
||||
)}
|
||||
>
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<CardDescription class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg',
|
||||
a.iconBg
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon class={cn('size-4', a.iconText)} />
|
||||
</span>
|
||||
<span class="truncate">{card.label}</span>
|
||||
</CardDescription>
|
||||
{#if card.href}
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
|
||||
<ArrowRight class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
|
||||
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Модули</CardTitle>
|
||||
<CardDescription
|
||||
>Запустить обновление вручную (CDN, домены, AS — в очередь; для IP_RANGES ответ 204)</CardDescription
|
||||
>
|
||||
<CardDescription>
|
||||
Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Модуль</TableHead>
|
||||
<TableHead>Тип</TableHead>
|
||||
<TableHead>Интервал</TableHead>
|
||||
<TableHead>Cron</TableHead>
|
||||
<TableHead class="w-28 text-right"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each modules as m (m.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium">{m.name}</TableCell>
|
||||
<TableCell><Badge variant="outline">{moduleTypeRu(m.type)}</Badge></TableCell>
|
||||
<TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell>
|
||||
<TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="secondary"
|
||||
disabled={!!refreshing[m.id]}
|
||||
onclick={() => refreshModule(m.id)}
|
||||
<CardContent class="p-4 pt-0">
|
||||
<AppDataTable
|
||||
columns={[...moduleColumns]}
|
||||
rows={modules}
|
||||
rowKey={(m) => m.id}
|
||||
loading={initialLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Нет модулей"
|
||||
emptyDescription="Создайте модуль на странице «Модули»."
|
||||
>
|
||||
{#snippet cell({ row: m, column })}
|
||||
{#if column.id === 'name'}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{m.name}</span>
|
||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else if column.id === 'type'}
|
||||
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||
{:else if column.id === 'schedule'}
|
||||
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||
{:else if column.id === 'refreshed'}
|
||||
<span class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(m.last_refreshed_at)}</span
|
||||
>
|
||||
{:else if column.id === 'status'}
|
||||
{#if m.enabled}
|
||||
<Badge variant="default" class="text-xs">Вкл</Badge>
|
||||
{:else}
|
||||
<Badge variant="secondary" class="text-xs">Выкл</Badge>
|
||||
{/if}
|
||||
{:else if column.id === 'actions'}
|
||||
<div class="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!!moduleRefreshing[m.id]}
|
||||
onclick={() => refreshModule(m.id)}
|
||||
>
|
||||
<RefreshCw class={moduleRefreshing[m.id] ? 'animate-spin' : ''} />
|
||||
{moduleRefreshing[m.id] ? '…' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle class="text-base">Задачи</CardTitle>
|
||||
<CardDescription>Последние 100 задач из API</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4 p-4 pt-0">
|
||||
<Tabs bind:value={jobsTab}>
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">
|
||||
Обновление модулей ({jobs.filter((j) => j.kind === 'module_refresh').length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({failedJobsCount})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={jobsTab} class="mt-4">
|
||||
<AppDataTable
|
||||
columns={[...jobColumns]}
|
||||
rows={filteredJobs}
|
||||
rowKey={(j) => j.job_id}
|
||||
loading={initialLoading}
|
||||
error={loadError}
|
||||
emptyTitle="Нет задач"
|
||||
emptyDescription={jobsTab === 'failed'
|
||||
? 'В выборке нет задач с ошибкой.'
|
||||
: jobsTab === 'refresh'
|
||||
? 'Задач обновления модулей пока нет.'
|
||||
: 'Задачи появятся после refresh или деплоя.'}
|
||||
>
|
||||
{#snippet cell({ row: j, column })}
|
||||
{#if column.id === 'kind'}
|
||||
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||
{:else if column.id === 'status'}
|
||||
<Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||
{:else if column.id === 'created'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(j.created_at)}</span
|
||||
>
|
||||
<RefreshCw class={refreshing[m.id] ? 'animate-spin' : ''} />
|
||||
{refreshing[m.id] ? '…' : 'Обновить'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-muted-foreground text-center py-6">
|
||||
{loading ? 'Загрузка…' : 'Нет модулей'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Последние задачи</CardTitle>
|
||||
<CardDescription>Задачи разложены по статусу и времени запуска.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Вид</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Ошибка</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each jobs as j (j.job_id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium">{jobKindTitle(j, moduleNameById)}</TableCell>
|
||||
<TableCell
|
||||
><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge
|
||||
></TableCell
|
||||
>
|
||||
<TableCell class="text-xs text-muted-foreground"
|
||||
>{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell
|
||||
>
|
||||
<TableCell class="max-w-xs truncate text-xs text-destructive"
|
||||
>{j.error ?? ''}</TableCell
|
||||
>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-muted-foreground text-center py-6">
|
||||
{loading ? 'Загрузка…' : 'Нет задач'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-base">Операции обновления модулей</CardTitle>
|
||||
<CardDescription
|
||||
>Отдельная лента задач обновления модулей для контроля по модулям.</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Создана</TableHead>
|
||||
<TableHead>Ревизия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each refreshJobs as j (j.job_id)}
|
||||
<TableRow>
|
||||
<TableCell
|
||||
><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge
|
||||
></TableCell
|
||||
>
|
||||
<TableCell class="text-xs"
|
||||
>{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell
|
||||
>
|
||||
<TableCell class="font-mono text-xs"
|
||||
>{typeof j.meta?.revision_id === 'string'
|
||||
? `${j.meta.revision_id.slice(0, 12)}…`
|
||||
: '—'}</TableCell
|
||||
>
|
||||
</TableRow>
|
||||
{:else}
|
||||
<TableRow>
|
||||
<TableCell colspan={3} class="text-muted-foreground text-center py-6"
|
||||
>Нет задач обновления модулей</TableCell
|
||||
>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{:else if column.id === 'finished'}
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||
>{formatDateTime(j.finished_at)}</span
|
||||
>
|
||||
{:else if column.id === 'error'}
|
||||
<span class="text-xs text-destructive">{truncateError(j.error)}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</AppDataTable>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user