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:
Denozordec
2026-05-20 12:10:11 +07:00
parent e558570967
commit ce747673fd
3 changed files with 403 additions and 245 deletions
+43
View File
@@ -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';
}
}
+6 -39
View File
@@ -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'}
+354 -206
View File
@@ -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&#123;id&#125;/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>