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 ExternalLink from '@lucide/svelte/icons/external-link';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
formatDateTime,
|
||||||
|
moduleIntervalLabel,
|
||||||
|
moduleTypeBadgeVariant
|
||||||
|
} from '$lib/modules/display.js';
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
|
|
||||||
let rows = $state<ModuleRow[]>([]);
|
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) {
|
function toggleModuleSelection(id: string) {
|
||||||
const next = new Set(selectedModuleIds);
|
const next = new Set(selectedModuleIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -255,7 +222,7 @@
|
|||||||
{:else if column.id === 'name'}
|
{:else if column.id === 'name'}
|
||||||
<span class="font-medium">{m.name}</span>
|
<span class="font-medium">{m.name}</span>
|
||||||
{:else if column.id === 'type'}
|
{: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'}
|
{:else if column.id === 'priority'}
|
||||||
<span class="text-muted-foreground">{m.priority}</span>
|
<span class="text-muted-foreground">{m.priority}</span>
|
||||||
{:else if column.id === 'interval'}
|
{:else if column.id === 'interval'}
|
||||||
|
|||||||
@@ -1,258 +1,406 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 type { JobRow, JobsResponse, ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import {
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
import {
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
Table,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
TableBody,
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
TableCell,
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
TableHead,
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
TableHeader,
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
TableRow
|
import { cn } from '$lib/utils.js';
|
||||||
} from '$lib/components/ui/table/index.js';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import ListTodo from '@lucide/svelte/icons/list-todo';
|
||||||
import { jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
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 modules = $state<ModuleRow[]>([]);
|
||||||
let jobs = $state<JobRow[]>([]);
|
let jobs = $state<JobRow[]>([]);
|
||||||
let refreshing = $state<Record<string, boolean>>({});
|
let moduleRefreshing = $state<Record<string, boolean>>({});
|
||||||
let loading = $state(false);
|
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() {
|
async function load() {
|
||||||
loading = true;
|
if (!initialLoading) refreshing = true;
|
||||||
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
const [m, j] = await Promise.all([
|
const [m, j] = await Promise.all([
|
||||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
apiJSON<JobsResponse>('/v1/jobs?limit=50')
|
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||||
]);
|
]);
|
||||||
modules = m.items ?? [];
|
modules = m.items ?? [];
|
||||||
jobs = j.items ?? [];
|
jobs = j.items ?? [];
|
||||||
|
lastUpdated = new Date();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
notifyApiError(e);
|
||||||
} finally {
|
} 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);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Расписание и задачи"
|
title="Расписание и задачи"
|
||||||
description="Интервалы обновления модулей и ручной запуск обновления."
|
description={lastUpdated
|
||||||
|
? `Интервалы обновления модулей и ручной запуск. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Интервалы обновления модулей и ручной запуск обновления.'}
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
iconClass="bg-chart-4/15 text-chart-4"
|
iconClass="bg-chart-4/15 text-chart-4"
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<Alert class="border-info/30 bg-info/5">
|
||||||
<Card class="p-4">
|
<Info class="text-info" />
|
||||||
<p class="text-xs text-muted-foreground">Всего задач</p>
|
<AlertTitle>Как работает расписание</AlertTitle>
|
||||||
<p class="text-lg font-semibold">{jobs.length}</p>
|
<AlertDescription>
|
||||||
</Card>
|
Планировщик использует <code class="text-xs">refresh_interval_sec</code> и опционально
|
||||||
<Card class="p-4">
|
<code class="text-xs">cron_expr</code>. Ручной запуск —
|
||||||
<p class="text-xs text-muted-foreground">В работе</p>
|
<code class="text-xs">POST /v1/modules{id}/refresh</code>; для модулей
|
||||||
<p class="text-lg font-semibold">{runningJobsCount}</p>
|
<code class="text-xs">IP_RANGES</code>
|
||||||
</Card>
|
сервер может вернуть <strong>204</strong>
|
||||||
<Card class="p-4">
|
(no-op). Полный список задач и деплой — на странице
|
||||||
<p class="text-xs text-muted-foreground">С ошибкой</p>
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
||||||
<p class="text-lg font-semibold">{failedJobsCount}</p>
|
</AlertDescription>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base">Модули</CardTitle>
|
<CardTitle class="text-base">Модули</CardTitle>
|
||||||
<CardDescription
|
<CardDescription>
|
||||||
>Запустить обновление вручную (CDN, домены, AS — в очередь; для IP_RANGES ответ 204)</CardDescription
|
Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204)
|
||||||
>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-4 pt-0">
|
||||||
<Table>
|
<AppDataTable
|
||||||
<TableHeader>
|
columns={[...moduleColumns]}
|
||||||
<TableRow>
|
rows={modules}
|
||||||
<TableHead>Модуль</TableHead>
|
rowKey={(m) => m.id}
|
||||||
<TableHead>Тип</TableHead>
|
loading={initialLoading}
|
||||||
<TableHead>Интервал</TableHead>
|
error={loadError}
|
||||||
<TableHead>Cron</TableHead>
|
emptyTitle="Нет модулей"
|
||||||
<TableHead class="w-28 text-right"></TableHead>
|
emptyDescription="Создайте модуль на странице «Модули»."
|
||||||
</TableRow>
|
>
|
||||||
</TableHeader>
|
{#snippet cell({ row: m, column })}
|
||||||
<TableBody>
|
{#if column.id === 'name'}
|
||||||
{#each modules as m (m.id)}
|
<div class="flex items-center gap-2">
|
||||||
<TableRow>
|
<span class="font-medium">{m.name}</span>
|
||||||
<TableCell class="font-medium">{m.name}</TableCell>
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
<TableCell><Badge variant="outline">{moduleTypeRu(m.type)}</Badge></TableCell>
|
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||||
<TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell>
|
</Button>
|
||||||
<TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell>
|
</div>
|
||||||
<TableCell class="text-right">
|
{:else if column.id === 'type'}
|
||||||
<Button
|
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||||
size="xs"
|
{:else if column.id === 'schedule'}
|
||||||
variant="secondary"
|
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||||
disabled={!!refreshing[m.id]}
|
{:else if column.id === 'refreshed'}
|
||||||
onclick={() => refreshModule(m.id)}
|
<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' : ''} />
|
{:else if column.id === 'finished'}
|
||||||
{refreshing[m.id] ? '…' : 'Обновить'}
|
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||||
</Button>
|
>{formatDateTime(j.finished_at)}</span
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
{:else if column.id === 'error'}
|
||||||
{:else}
|
<span class="text-xs text-destructive">{truncateError(j.error)}</span>
|
||||||
<TableRow>
|
{/if}
|
||||||
<TableCell colspan={5} class="text-muted-foreground text-center py-6">
|
{/snippet}
|
||||||
{loading ? 'Загрузка…' : 'Нет модулей'}
|
</AppDataTable>
|
||||||
</TableCell>
|
</TabsContent>
|
||||||
</TableRow>
|
</Tabs>
|
||||||
{/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>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user