feat!(web): migrate UI from SvelteKit to React + shadcn/ui + ReUI
CI / changes (push) Successful in 17s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Failing after 2m22s
CI / changes (push) Successful in 17s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Failing after 2m22s
Web UI полностью переведён с SvelteKit на новый стек: React 19, TanStack Router/Query/Table/Virtual, shadcn/ui (base-nova) и ReUI enterprise-компоненты (data-grid, filters, autocomplete). Новый код разложен по слоям: packages/ui (shadcn-примитивы), apps/web (роуты, shared-обёртки, ReUI-адаптации). BREAKING CHANGE: меняется структура и инструментинг фронтенда. - apps/web/ — новый Vite + React-проект (@evobgp/web), file-based роуты TanStack Router; экраны dashboard, modules, monitoring, network, operations, schedule, settings, tenant-settings, access, directories. - packages/ui/ — shadcn/ui-примитивы (@evobgp/ui) с общими стилями globals.css и cn-утилитой; CLI shadcn запускается из apps/web. - apps/web/src/components/reui/ — enterprise-паттерны ReUI. - pnpm workspace (pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.base.json) заменяет npm-проект в web/. - web/ переименован в web-legacy-svelte/ (архив-референс для миграции); импорты оттуда запрещены правилом WEB-22. - CI (.gitea/workflows/ci.yaml): job web переведён на Node 22 + pnpm 10 (typecheck/lint/build через pnpm --filter @evobgp/web); пути триггеров обновлены под apps/web|packages/ui. - deploy/docker/evobgp-web/Dockerfile: сборка из корня репозитория, pnpm install --frozen-lockfile, выход dist из apps/web/dist. - .cursor/rules/web-shadcn.mdc, context7-stack.mdc, engineering.mdc, AGENTS.md — обновлены под React-стек (WEB-01..WEB-22, DOC-SYNC-06/07). Проверки WEB-19 локально: typecheck, lint, build — exit 0. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
|
||||
export type MaintenancePolicy = {
|
||||
id: string;
|
||||
name: string;
|
||||
table_name: string;
|
||||
condition: string;
|
||||
retention_period_sec?: number;
|
||||
max_rows?: number;
|
||||
vacuum_strategy: string;
|
||||
schedule: string;
|
||||
enabled: boolean;
|
||||
dry_run_enabled: boolean;
|
||||
last_run_at?: string;
|
||||
last_status?: string;
|
||||
last_error?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type MaintenancePolicyHints = {
|
||||
table_name: string;
|
||||
n_dead_tup: number;
|
||||
bloat_ratio?: number;
|
||||
last_autovacuum?: string;
|
||||
recommend_vacuum: boolean;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type MaintenancePoliciesResponse = {
|
||||
items: MaintenancePolicy[];
|
||||
next_cursor?: string;
|
||||
has_more?: boolean;
|
||||
};
|
||||
|
||||
export async function listMaintenancePolicies(limit = 100): Promise<MaintenancePolicy[]> {
|
||||
const r = await apiJSON<MaintenancePoliciesResponse>(`/v1/maintenance/policies?limit=${limit}`);
|
||||
return r.items ?? [];
|
||||
}
|
||||
|
||||
export async function createMaintenancePolicy(
|
||||
body: Record<string, unknown>
|
||||
): Promise<MaintenancePolicy> {
|
||||
return apiMutate<MaintenancePolicy>('/v1/maintenance/policies', 'POST', body);
|
||||
}
|
||||
|
||||
export async function updateMaintenancePolicy(
|
||||
id: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<MaintenancePolicy> {
|
||||
return apiMutate<MaintenancePolicy>(`/v1/maintenance/policies/${id}`, 'PATCH', body);
|
||||
}
|
||||
|
||||
export async function deleteMaintenancePolicy(id: string): Promise<void> {
|
||||
await apiMutate(`/v1/maintenance/policies/${id}`, 'DELETE', undefined, { idempotent: false });
|
||||
}
|
||||
|
||||
export async function runMaintenancePolicy(
|
||||
id: string,
|
||||
dryRun: boolean
|
||||
): Promise<{ job_id: string }> {
|
||||
const path = dryRun ? '/v1/maintenance/dry-run' : '/v1/maintenance/run';
|
||||
return apiMutate<{ job_id: string; status: string }>(path, 'POST', { policy_id: id });
|
||||
}
|
||||
|
||||
export async function fetchPolicyHints(id: string): Promise<MaintenancePolicyHints> {
|
||||
return apiJSON<MaintenancePolicyHints>(`/v1/maintenance/policies/${id}/hints`);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { MaintenancePolicy } from '$lib/maintenance/policy-api.js';
|
||||
import type { MaintenancePolicyForm } from '$lib/maintenance/policy.schema.js';
|
||||
|
||||
const DAY_SEC = 86_400;
|
||||
|
||||
/** Рекомендуемый шаблон политики (только UI; в БД не seed'ится). */
|
||||
export type MaintenancePolicyPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Подсказка: таблица должна быть видна в pg_stat (не блокирует создание). */
|
||||
tableHint?: string;
|
||||
form: MaintenancePolicyForm;
|
||||
};
|
||||
|
||||
/** Базовые пресеты EvoBGP — оператор выбирает, какие создать. */
|
||||
export const maintenancePolicyPresets: MaintenancePolicyPreset[] = [
|
||||
{
|
||||
id: 'job_audit_retention',
|
||||
label: 'Job audit — retention 90d',
|
||||
description:
|
||||
'Удаляет завершённые записи job_audit старше 90 дней; VACUUM ANALYZE после очистки. Расписание 03:00 UTC.',
|
||||
tableHint: 'job_audit',
|
||||
form: {
|
||||
name: 'Job audit retention (90d)',
|
||||
table_name: 'job_audit',
|
||||
condition: "status IN ('succeeded', 'failed', 'cancelled')",
|
||||
retention_period_sec: String(90 * DAY_SEC),
|
||||
max_rows: '10000',
|
||||
vacuum_strategy: 'vacuum_analyze',
|
||||
schedule: '0 3 * * *',
|
||||
enabled: true,
|
||||
dry_run_enabled: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'runtime_log_cleanup_audit_retention',
|
||||
label: 'Runtime log cleanup audit — 90d',
|
||||
description:
|
||||
'Удаляет записи runtime_log_cleanup_audit старше 90 дней (ручная и автоочистка FS).',
|
||||
tableHint: 'runtime_log_cleanup_audit',
|
||||
form: {
|
||||
name: 'Runtime log cleanup audit (90d)',
|
||||
table_name: 'runtime_log_cleanup_audit',
|
||||
condition: 'true',
|
||||
retention_period_sec: String(90 * DAY_SEC),
|
||||
max_rows: '5000',
|
||||
vacuum_strategy: 'none',
|
||||
schedule: '0 4 * * *',
|
||||
enabled: true,
|
||||
dry_run_enabled: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'postgres_maintenance_audit_retention',
|
||||
label: 'Maintenance audit — 30d',
|
||||
description: 'Очищает postgres_maintenance_audit старше 30 дней без vacuum.',
|
||||
tableHint: 'postgres_maintenance_audit',
|
||||
form: {
|
||||
name: 'Postgres maintenance audit (30d)',
|
||||
table_name: 'postgres_maintenance_audit',
|
||||
condition: 'true',
|
||||
retention_period_sec: String(30 * DAY_SEC),
|
||||
max_rows: '5000',
|
||||
vacuum_strategy: 'none',
|
||||
schedule: '0 4 * * *',
|
||||
enabled: true,
|
||||
dry_run_enabled: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'job_audit_vacuum_weekly',
|
||||
label: 'Job audit — VACUUM weekly',
|
||||
description: 'Только VACUUM ANALYZE job_audit по воскресеньям, без удаления строк.',
|
||||
tableHint: 'job_audit',
|
||||
form: {
|
||||
name: 'Job audit vacuum (weekly)',
|
||||
table_name: 'job_audit',
|
||||
condition: 'true',
|
||||
retention_period_sec: '',
|
||||
max_rows: '',
|
||||
vacuum_strategy: 'vacuum_analyze',
|
||||
schedule: '0 2 * * 0',
|
||||
enabled: true,
|
||||
dry_run_enabled: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'postgres_monitor_snapshot',
|
||||
label: 'PG monitor snapshots — 14d',
|
||||
description:
|
||||
'Удаляет снимки postgres_monitor_snapshot старше 14 дней (фильтр по collected_at в condition).',
|
||||
tableHint: 'postgres_monitor_snapshot',
|
||||
form: {
|
||||
name: 'Postgres monitor snapshots (14d)',
|
||||
table_name: 'postgres_monitor_snapshot',
|
||||
condition: "collected_at < NOW() - INTERVAL '14 days'",
|
||||
retention_period_sec: '',
|
||||
max_rows: '10000',
|
||||
vacuum_strategy: 'none',
|
||||
schedule: '0 5 * * *',
|
||||
enabled: true,
|
||||
dry_run_enabled: true
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'config_revision_retention',
|
||||
label: 'Config revisions — 180d',
|
||||
description:
|
||||
'Долгое хранение старых config_revision (180d). Перед включением проверьте revision_retention в настройках.',
|
||||
tableHint: 'config_revision',
|
||||
form: {
|
||||
name: 'Config revision retention (180d)',
|
||||
table_name: 'config_revision',
|
||||
condition: 'true',
|
||||
retention_period_sec: String(180 * DAY_SEC),
|
||||
max_rows: '5000',
|
||||
vacuum_strategy: 'vacuum',
|
||||
schedule: '0 6 * * 0',
|
||||
enabled: false,
|
||||
dry_run_enabled: true
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export function presetForm(preset: MaintenancePolicyPreset): MaintenancePolicyForm {
|
||||
return structuredClone(preset.form);
|
||||
}
|
||||
|
||||
/** Политика с тем же именем и таблицей считается уже созданной из пресета. */
|
||||
export function isPresetAlreadyApplied(
|
||||
preset: MaintenancePolicyPreset,
|
||||
policies: MaintenancePolicy[]
|
||||
): boolean {
|
||||
return policies.some(
|
||||
(p) => p.name === preset.form.name.trim() && p.table_name === preset.form.table_name.trim()
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAvailablePresets(
|
||||
policies: MaintenancePolicy[],
|
||||
selected: Iterable<string>
|
||||
): MaintenancePolicyPreset[] {
|
||||
const ids = new Set(selected);
|
||||
return maintenancePolicyPresets.filter(
|
||||
(p) => ids.has(p.id) && !isPresetAlreadyApplied(p, policies)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/** Режимы расписания (5-field cron, UTC) — совместимы с robfig/cron в scheduler. */
|
||||
export type ScheduleMode = 'minutes' | 'hours' | 'daily' | 'weekly' | 'custom';
|
||||
|
||||
export type ScheduleEditor = {
|
||||
mode: ScheduleMode;
|
||||
/** Интервал в минутах (режим minutes). */
|
||||
intervalMinutes: string;
|
||||
/** Интервал в часах (режим hours). */
|
||||
intervalHours: string;
|
||||
minute: string;
|
||||
hour: string;
|
||||
/** 0 = воскресенье … 6 = суббота */
|
||||
weekday: string;
|
||||
customCron: string;
|
||||
};
|
||||
|
||||
export const scheduleModeOptions: { value: ScheduleMode; label: string }[] = [
|
||||
{ value: 'minutes', label: 'Каждые N минут' },
|
||||
{ value: 'hours', label: 'Каждые N часов' },
|
||||
{ value: 'daily', label: 'Ежедневно в указанное время' },
|
||||
{ value: 'weekly', label: 'Еженедельно в указанный день' },
|
||||
{ value: 'custom', label: 'Cron вручную (расширенный)' }
|
||||
];
|
||||
|
||||
export const weekdayOptions = [
|
||||
{ value: '0', label: 'Воскресенье' },
|
||||
{ value: '1', label: 'Понедельник' },
|
||||
{ value: '2', label: 'Вторник' },
|
||||
{ value: '3', label: 'Среда' },
|
||||
{ value: '4', label: 'Четверг' },
|
||||
{ value: '5', label: 'Пятница' },
|
||||
{ value: '6', label: 'Суббота' }
|
||||
] as const;
|
||||
|
||||
export function defaultScheduleEditor(cron = '0 3 * * *'): ScheduleEditor {
|
||||
return cronToEditor(cron);
|
||||
}
|
||||
|
||||
function splitCron(cron: string): [string, string, string, string, string] | null {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
return [parts[0], parts[1], parts[2], parts[3], parts[4]];
|
||||
}
|
||||
|
||||
function clampInt(raw: string, min: number, max: number): number {
|
||||
const n = Math.floor(Number(String(raw).trim()));
|
||||
if (!Number.isFinite(n)) return min;
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Пытается разобрать cron в редактор; неизвестные выражения → custom. */
|
||||
export function cronToEditor(cron: string): ScheduleEditor {
|
||||
const base: ScheduleEditor = {
|
||||
mode: 'custom',
|
||||
intervalMinutes: '30',
|
||||
intervalHours: '2',
|
||||
minute: '0',
|
||||
hour: '3',
|
||||
weekday: '0',
|
||||
customCron: cron.trim() || '0 3 * * *'
|
||||
};
|
||||
|
||||
const parts = splitCron(cron);
|
||||
if (!parts) return base;
|
||||
|
||||
const [min, hour, dom, month, dow] = parts;
|
||||
|
||||
const minEvery = min.match(/^\*\/(\d+)$/);
|
||||
if (minEvery && hour === '*' && dom === '*' && month === '*' && dow === '*') {
|
||||
return { ...base, mode: 'minutes', intervalMinutes: minEvery[1], customCron: cron.trim() };
|
||||
}
|
||||
|
||||
const hourEvery = hour.match(/^\*\/(\d+)$/);
|
||||
if (hourEvery && !min.includes('*') && dom === '*' && month === '*' && dow === '*') {
|
||||
return {
|
||||
...base,
|
||||
mode: 'hours',
|
||||
intervalHours: hourEvery[1],
|
||||
minute: min,
|
||||
customCron: cron.trim()
|
||||
};
|
||||
}
|
||||
|
||||
if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow === '*') {
|
||||
return { ...base, mode: 'daily', minute: min, hour, customCron: cron.trim() };
|
||||
}
|
||||
|
||||
if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow !== '*') {
|
||||
return { ...base, mode: 'weekly', minute: min, hour, weekday: dow, customCron: cron.trim() };
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Собирает 5-field cron из редактора. */
|
||||
export function editorToCron(editor: ScheduleEditor): string {
|
||||
switch (editor.mode) {
|
||||
case 'minutes': {
|
||||
const n = clampInt(editor.intervalMinutes, 1, 59);
|
||||
return `*/${n} * * * *`;
|
||||
}
|
||||
case 'hours': {
|
||||
const n = clampInt(editor.intervalHours, 1, 23);
|
||||
const m = clampInt(editor.minute, 0, 59);
|
||||
return `${m} */${n} * * *`;
|
||||
}
|
||||
case 'daily': {
|
||||
const m = clampInt(editor.minute, 0, 59);
|
||||
const h = clampInt(editor.hour, 0, 23);
|
||||
return `${m} ${h} * * *`;
|
||||
}
|
||||
case 'weekly': {
|
||||
const m = clampInt(editor.minute, 0, 59);
|
||||
const h = clampInt(editor.hour, 0, 23);
|
||||
const d = clampInt(editor.weekday, 0, 6);
|
||||
return `${m} ${h} * * ${d}`;
|
||||
}
|
||||
case 'custom':
|
||||
return editor.customCron.trim() || '0 3 * * *';
|
||||
}
|
||||
}
|
||||
|
||||
/** Человекочитаемое описание расписания для таблицы и подсказок. */
|
||||
export function describeCron(cron: string): string {
|
||||
const ed = cronToEditor(cron);
|
||||
switch (ed.mode) {
|
||||
case 'minutes': {
|
||||
const n = clampInt(ed.intervalMinutes, 1, 59);
|
||||
return `Каждые ${n} ${minutesLabel(n)} (UTC)`;
|
||||
}
|
||||
case 'hours': {
|
||||
const n = clampInt(ed.intervalHours, 1, 23);
|
||||
const m = clampInt(ed.minute, 0, 59);
|
||||
return `Каждые ${n} ${hoursLabel(n)}, в :${pad2(m)} (UTC)`;
|
||||
}
|
||||
case 'daily': {
|
||||
const h = clampInt(ed.hour, 0, 23);
|
||||
const m = clampInt(ed.minute, 0, 59);
|
||||
return `Ежедневно в ${pad2(h)}:${pad2(m)} UTC`;
|
||||
}
|
||||
case 'weekly': {
|
||||
const wd =
|
||||
weekdayOptions.find((w) => w.value === String(clampInt(ed.weekday, 0, 6)))?.label ??
|
||||
ed.weekday;
|
||||
const h = clampInt(ed.hour, 0, 23);
|
||||
const m = clampInt(ed.minute, 0, 59);
|
||||
return `Каждую ${wd.toLowerCase()} в ${pad2(h)}:${pad2(m)} UTC`;
|
||||
}
|
||||
case 'custom':
|
||||
return `Cron: ${cron.trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Синхронизирует form.schedule из редактора. */
|
||||
export function applyScheduleEditor(editor: ScheduleEditor): {
|
||||
editor: ScheduleEditor;
|
||||
cron: string;
|
||||
} {
|
||||
if (editor.mode === 'custom') {
|
||||
const cron = editor.customCron.trim() || '0 3 * * *';
|
||||
return { editor: { ...editor, customCron: cron }, cron };
|
||||
}
|
||||
const cron = editorToCron(editor);
|
||||
return { editor: { ...editor, customCron: cron }, cron };
|
||||
}
|
||||
|
||||
function minutesLabel(n: number): string {
|
||||
if (n % 10 === 1 && n % 100 !== 11) return 'минуту';
|
||||
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'минуты';
|
||||
return 'минут';
|
||||
}
|
||||
|
||||
function hoursLabel(n: number): string {
|
||||
if (n % 10 === 1 && n % 100 !== 11) return 'час';
|
||||
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'часа';
|
||||
return 'часов';
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const vacuumStrategies = ['none', 'vacuum', 'analyze', 'vacuum_analyze', 'reindex'] as const;
|
||||
|
||||
export type VacuumStrategy = (typeof vacuumStrategies)[number];
|
||||
|
||||
export const maintenancePolicySchema = z.object({
|
||||
name: z.string().trim().min(1, 'Укажите название'),
|
||||
table_name: z.string().trim().min(1, 'Укажите таблицу'),
|
||||
condition: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Укажите условие')
|
||||
.refine((v) => !/[;]|--|\/\*/.test(v), 'Недопустимые символы в condition'),
|
||||
retention_period_sec: z.string().optional(),
|
||||
max_rows: z.string().optional(),
|
||||
vacuum_strategy: z.enum(vacuumStrategies),
|
||||
schedule: z.string().trim().min(1, 'Укажите cron (UTC)'),
|
||||
enabled: z.boolean(),
|
||||
dry_run_enabled: z.boolean()
|
||||
});
|
||||
|
||||
export type MaintenancePolicyForm = z.infer<typeof maintenancePolicySchema>;
|
||||
|
||||
export function emptyMaintenancePolicyForm(): MaintenancePolicyForm {
|
||||
return {
|
||||
name: '',
|
||||
table_name: '',
|
||||
condition: 'true',
|
||||
retention_period_sec: '',
|
||||
max_rows: '10000',
|
||||
vacuum_strategy: 'none',
|
||||
schedule: '0 3 * * *',
|
||||
enabled: true,
|
||||
dry_run_enabled: true
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOptionalInt(raw: string | undefined): number | undefined {
|
||||
const v = String(raw ?? '').trim();
|
||||
if (!v) return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
||||
}
|
||||
|
||||
export function formToPayload(form: MaintenancePolicyForm) {
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
table_name: form.table_name.trim(),
|
||||
condition: form.condition.trim() || 'true',
|
||||
retention_period_sec: parseOptionalInt(form.retention_period_sec),
|
||||
max_rows: parseOptionalInt(form.max_rows),
|
||||
vacuum_strategy: form.vacuum_strategy,
|
||||
schedule: form.schedule.trim(),
|
||||
enabled: form.enabled,
|
||||
dry_run_enabled: form.dry_run_enabled
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user