diff --git a/web/src/lib/modules/display.ts b/web/src/lib/modules/display.ts new file mode 100644 index 0000000..4dca283 --- /dev/null +++ b/web/src/lib/modules/display.ts @@ -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'; + } +} diff --git a/web/src/routes/modules/+page.svelte b/web/src/routes/modules/+page.svelte index 4be8dd0..20b0944 100644 --- a/web/src/routes/modules/+page.svelte +++ b/web/src/routes/modules/+page.svelte @@ -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([]); @@ -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'} {m.name} {:else if column.id === 'type'} - {moduleTypeRu(m.type)} + {moduleTypeRu(m.type)} {:else if column.id === 'priority'} {m.priority} {:else if column.id === 'interval'} diff --git a/web/src/routes/schedule/+page.svelte b/web/src/routes/schedule/+page.svelte index 10c3fb0..b944e6f 100644 --- a/web/src/routes/schedule/+page.svelte +++ b/web/src/routes/schedule/+page.svelte @@ -1,258 +1,406 @@
{#snippet actions()} - {/snippet} -
- -

Всего задач

-

{jobs.length}

-
- -

В работе

-

{runningJobsCount}

-
- -

С ошибкой

-

{failedJobsCount}

-
+ + + Как работает расписание + + Планировщик использует refresh_interval_sec и опционально + cron_expr. Ручной запуск — + POST /v1/modules{id}/refresh; для модулей + IP_RANGES + сервер может вернуть 204 + (no-op). Полный список задач и деплой — на странице + . + + + +
+ {#if initialLoading} + {#each Array(3) as _, i (i)} + + {/each} + {:else} + {#each kpiCards as card (card.id)} + {@const Icon = card.icon} + {@const a = card.accent} + + +
+ + + {card.label} + + {#if card.href} + + {/if} +
+ {card.value} +
+ + {card.badge} +

{card.description}

+
+
+ {/each} + {/if}
Модули - Запустить обновление вручную (CDN, домены, AS — в очередь; для IP_RANGES ответ 204) + + Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204) + - - - - - Модуль - Тип - Интервал - Cron - - - - - {#each modules as m (m.id)} - - {m.name} - {moduleTypeRu(m.type)} - {intervalLabel(m.refresh_interval_sec)} - {m.cron_expr || '—'} - - + + {:else if column.id === 'type'} + {moduleTypeRu(m.type)} + {:else if column.id === 'schedule'} + {moduleIntervalLabel(m)} + {:else if column.id === 'refreshed'} + {formatDateTime(m.last_refreshed_at)} + {:else if column.id === 'status'} + {#if m.enabled} + Вкл + {:else} + Выкл + {/if} + {:else if column.id === 'actions'} +
+ +
+ {/if} + {/snippet} + + + + + + +
+
+ Задачи + Последние 100 задач из API +
+ +
+
+ + + + Все ({jobs.length}) + + Обновление модулей ({jobs.filter((j) => j.kind === 'module_refresh').length}) + + С ошибкой ({failedJobsCount}) + + + + j.job_id} + loading={initialLoading} + error={loadError} + emptyTitle="Нет задач" + emptyDescription={jobsTab === 'failed' + ? 'В выборке нет задач с ошибкой.' + : jobsTab === 'refresh' + ? 'Задач обновления модулей пока нет.' + : 'Задачи появятся после refresh или деплоя.'} + > + {#snippet cell({ row: j, column })} + {#if column.id === 'kind'} + {jobKindTitle(j, moduleNameById)} + {:else if column.id === 'status'} + {jobStatusRu(j.status)} + {:else if column.id === 'created'} + {formatDateTime(j.created_at)} - - {refreshing[m.id] ? '…' : 'Обновить'} - -
-
- {:else} - - - {loading ? 'Загрузка…' : 'Нет модулей'} - - - {/each} -
-
-
-
- - - - Последние задачи - Задачи разложены по статусу и времени запуска. - - - - - - Вид - Статус - Создана - Ошибка - - - - {#each jobs as j (j.job_id)} - - {jobKindTitle(j, moduleNameById)} - {jobStatusRu(j.status)} - {j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'} - {j.error ?? ''} - - {:else} - - - {loading ? 'Загрузка…' : 'Нет задач'} - - - {/each} - -
-
-
- - - - Операции обновления модулей - Отдельная лента задач обновления модулей для контроля по модулям. - - - - - - Статус - Создана - Ревизия - - - - {#each refreshJobs as j (j.job_id)} - - {jobStatusRu(j.status)} - {j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'} - {typeof j.meta?.revision_id === 'string' - ? `${j.meta.revision_id.slice(0, 12)}…` - : '—'} - - {:else} - - Нет задач обновления модулей - - {/each} - -
+ {:else if column.id === 'finished'} + {formatDateTime(j.finished_at)} + {:else if column.id === 'error'} + {truncateError(j.error)} + {/if} + {/snippet} + + +