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

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:
Denozordec
2026-07-02 17:59:59 +07:00
co-authored by Cursor
parent db75126bea
commit c144b49acf
417 changed files with 25450 additions and 124 deletions
@@ -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
};
}