From d0f56497257f3f2978eeddea74ed741926074c29 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 29 Sep 2026 13:42:00 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=83=D1=81=D1=82=D1=83=D1=8E=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8E=20WireGuard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/wireguard/page.tsx | 26 ++++++++----- components/config-history-sheet.tsx | 9 +++++ docs/ux-ui-plan.md | 4 +- docs/ux-ui-progress.md | 10 ++++- docs/ux-ui-stage-9.md | 6 ++- shared/api/wireguard.ts | 16 ++++++++ tests/e2e/wireguard-links.spec.ts | 59 +++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/app/(main)/wireguard/page.tsx b/app/(main)/wireguard/page.tsx index 062d4059..c2bcb68e 100644 --- a/app/(main)/wireguard/page.tsx +++ b/app/(main)/wireguard/page.tsx @@ -46,6 +46,7 @@ import { exportWireGuard, importWireGuard, listWireGuard, + listWireGuardRevisions, patchWireGuardInterface, } from "@/shared/api/wireguard" import type { WgIfaceDto } from "@mmapp/contracts/wireguard" @@ -211,6 +212,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf const [historyOpen, setHistoryOpen] = useState(false) const [revisions, setRevisions] = useState([]) const [historyLoading, setHistoryLoading] = useState(false) + const [historyError, setHistoryError] = useState(null) + const historyEpoch = useRef(0) + useEffect(() => () => { historyEpoch.current += 1 }, []) const restoreLock = useRef(false) const [historyRestoring, setHistoryRestoring] = useState(false) const [liveExport, setLiveExport] = useState<{ @@ -272,18 +276,18 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf const loadRevisions = useCallback(async () => { if (!isLive || !historyServerId) return + const epoch = ++historyEpoch.current setHistoryLoading(true) + setHistoryError(null) try { - const res = await requestJson<{ revisions: ConfigRevisionDto[] }>( - backendUrl, - `/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`, - ) - setRevisions(res.revisions) - } catch (err) { - toast.error("Не удалось загрузить историю", { description: String(err) }) - setRevisions([]) + const revisions = await listWireGuardRevisions(backendUrl, historyServerId) + if (epoch !== historyEpoch.current) return + setRevisions(revisions) + } catch (error) { + if (epoch !== historyEpoch.current) return + setHistoryError(error instanceof Error ? error.message : "Не удалось загрузить историю") } finally { - setHistoryLoading(false) + if (epoch === historyEpoch.current) setHistoryLoading(false) } }, [isLive, historyServerId, backendUrl]) @@ -990,7 +994,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf { if (!v) historyEpoch.current += 1; setHistoryOpen(v) }} + loadError={historyError} + onRetry={() => void loadRevisions()} title="История WireGuard" itemLabel="интерфейсов" revisions={revisions} diff --git a/components/config-history-sheet.tsx b/components/config-history-sheet.tsx index 6bfecb5a..922e67f4 100644 --- a/components/config-history-sheet.tsx +++ b/components/config-history-sheet.tsx @@ -65,6 +65,8 @@ export function ConfigHistorySheet({ itemLabel, revisions, loading, + loadError, + onRetry, restoring, onRestore, }: { @@ -74,6 +76,8 @@ export function ConfigHistorySheet({ itemLabel: string revisions: ConfigRevisionDto[] loading: boolean + loadError?: string | null + onRetry?: () => void restoring: boolean onRestore: (id: string) => Promise | void }) { @@ -122,6 +126,11 @@ export function ConfigHistorySheet({ Загрузка истории… + ) : loadError ? ( +
+

Не удалось загрузить историю: {loadError}

+ {onRetry && } +
) : newestFirst.length === 0 ? ( } diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index b0a3ef95..1f4a7560 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -80,7 +80,7 @@ flowchart LR ## Текущая точка продолжения -По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.9 — операции WireGuard и общий диалог восстановления); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. +По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.10 — операции и история WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки. Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md). @@ -88,7 +88,7 @@ flowchart LR Ближайший оставшийся объём: -1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. Далее — состояния загрузки истории и защита от поздних ответов; затем IPsec/GRE/VXLAN/Firewall/Filters. +1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение, контекст экспорта и выбор пира WireGuard исправлены в 9.1–9.7; в 9.8 проверено подтверждение восстановления. В 9.9 исправлены разметка и сохранение подтверждения при ошибке у всех шести потребителей. В 9.10 исправлены ошибки и поздние ответы загрузки истории WireGuard. Далее — подтверждение восстановления и состояния истории остальных разделов; затем IPsec/GRE/VXLAN/Firewall/Filters. 2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения. 3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fbfaa672..661bda9f 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,20 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.9 — операции WireGuard и общий диалог восстановления | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.10 — операции и загрузка истории WireGuard | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.10 — состояния загрузки истории WireGuard (2026-09-29) + +- Ошибка истории больше не подменяется пустым списком. ConfigHistorySheet получил необязательные loadError/onRetry; WireGuard показывает ошибку через role=alert с повтором. Пустое состояние доступно только после успешного пустого ответа. Во время ошибки прежние ревизии не предлагаются для отката; данные остаются в состоянии, но скрыты до успешной загрузки. +- listWireGuardRevisions проверяет поля ревизий, раздел wireguard и совпадение serverId. Повреждённый ответ и история другого сервера отклоняются. Новое поколение каждого запроса и инвалидирование при закрытии/размонтировании защищают результат, ошибку и finally от поздних ответов. +- Три новых E2E прошли: 403 → повреждённый ответ → чужой serverId → корректный пустой список; поздний успех и поздняя ошибка после переоткрытия не снимают новую загрузку. Итоговый набор из 33 E2E WireGuard и всех шести потребителей диалога истории прошёл. Lint изменённых файлов, TypeScript и diff-check прошли. +- Получен ReUI checklist; общий компонент сохраняет прежнее поведение для потребителей без новых props. Реальные RouterOS/JWT не проверены. Запросы после закрытия физически не отменяются, их ответы игнорируются. Production build не повторялся; последний успешный — 9.9. Исходные lint-ограничения остальных страниц из 9.9 сохраняются. +- Далее — подтвердить ok=true восстановления IPsec/GRE/Firewall/Filters/рекурсивных маршрутов; затем распространить обработку ошибок загрузки истории и продолжить профильный UX сетевых разделов. Этап 9 открыт; локальный коммит без push. + ## Блок 9.9 — общий диалог восстановления (2026-09-29) - ConfigHistorySheet закрывает подтверждение только после успешного завершения onRestore. Ошибка остаётся внутри через role=alert, выбранная ревизия сохраняется для повтора. Синхронный замок предотвращает двойной вызов; ожидание объявляется через role=status. Во время операции нельзя закрыть подтверждение или панель истории. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index b3e190fd..b1908272 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -1,6 +1,6 @@ # Этап 9: VPN и сетевые операции -Статус: начат 2026-09-28, выполнены блоки 9.1–9.9. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. +Статус: начат 2026-09-28, выполнены блоки 9.1–9.10. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем. ## Схема и референсы принятого плана @@ -77,3 +77,7 @@ ## Блок 9.9 — общий диалог восстановления (2026-09-29) Исправлена HTML-разметка предупреждения; ошибка сохраняет выбранную ревизию и подтверждение для повтора. Общий замок блокирует повтор и закрытие до завершения Promise. Ошибки всех шести потребителей передаются в диалог; IPsec возвращает Promise. Все 40 профильных E2E прошли, включая 6 новых сценариев истории. Общий компонент и тесты lint-чистые; 7 исходных ошибок и 31 предупреждение в страницах подтверждены сравнением с HEAD и не изменились. Сборка — в журнале. Далее — ошибки/гонки загрузки истории, затем подтверждение восстановления остальных разделов. Реальные устройства не проверены. + +## Блок 9.10 — загрузка истории WireGuard (2026-09-29) + +Ошибка отделена от пустого списка и имеет повтор; ревизии проверяются по схеме и выбранному серверу. Закрытие/размонтирование и новое поколение запроса исключают поздние результат/ошибку/finally. Три новых E2E, lint и TypeScript прошли; итог регрессии в журнале. Остальные страницы пока не подключены к новым loadError/onRetry. Далее — подтверждение восстановления остальных разделов и их состояния истории. diff --git a/shared/api/wireguard.ts b/shared/api/wireguard.ts index 72368d08..860139a4 100644 --- a/shared/api/wireguard.ts +++ b/shared/api/wireguard.ts @@ -1,3 +1,4 @@ +import { z } from "zod" import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard" import type { WgCreateInterface, @@ -126,3 +127,18 @@ export async function exportWireGuard( } return parsed.data } + +const wireguardRevisionsSchema = z.object({ revisions: z.array(z.object({ + id: z.string().min(1), serverId: z.string().min(1), section: z.literal("wireguard"), + source: z.enum(["apply", "rollback", "observed", "copy"]), fingerprint: z.string(), + createdAt: z.string().refine(value => Number.isFinite(Date.parse(value))), + note: z.string().nullable(), itemCount: z.number().int().nonnegative(), +})) }) + +export async function listWireGuardRevisions(baseUrl: string, serverId: string) { + const result = wireguardRevisionsSchema.safeParse(await requestJson(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId))) + if (!result.success || result.data.revisions.some(revision => revision.serverId !== serverId)) { + throw new Error("Некорректный ответ истории WireGuard") + } + return result.data.revisions +} diff --git a/tests/e2e/wireguard-links.spec.ts b/tests/e2e/wireguard-links.spec.ts index 05274547..c92b080a 100644 --- a/tests/e2e/wireguard-links.spec.ts +++ b/tests/e2e/wireguard-links.spec.ts @@ -468,3 +468,62 @@ test("WireGuard: восстановление требует подтвержд await expect(page.getByText("Версия применена на роутер", { exact: true })).toBeVisible() expect(attempts).toBe(2) }) + +test("WireGuard: ошибка истории не становится пустым списком", async ({ page }) => { + await setup(page, "/wireguard?server=1") + let attempts = 0 + await page.route("**/api/wireguard/revisions?**", route => { + attempts++ + if (attempts === 1) return route.fulfill({ status: 403, json: { error: "История запрещена тест" } }) + if (attempts === 2) return route.fulfill({ json: {} }) + if (attempts === 3) return route.fulfill({ json: { revisions: [{ id: "wrong", serverId: "2", section: "wireguard", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] } }) + return route.fulfill({ json: { revisions: [] } }) + }) + await page.getByRole("button", { name: "История", exact: true }).click() + const sheet = page.getByRole("dialog", { name: "История WireGuard" }) + for (const message of ["История запрещена тест", "Некорректный ответ истории WireGuard", "Некорректный ответ истории WireGuard"]) { + await expect(sheet.getByRole("alert")).toContainText(message) + await expect(sheet.getByText("Истории пока нет", { exact: true })).toHaveCount(0) + await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toHaveCount(0) + await sheet.getByRole("button", { name: "Повторить загрузку" }).click() + } + await expect(sheet.getByText("Истории пока нет", { exact: true })).toBeVisible() + await expect(sheet.getByRole("alert")).toHaveCount(0) + expect(attempts).toBe(4) +}) + +for (const oldFails of [false, true]) { + test("WireGuard: закрытая история не меняет новый запрос " + oldFails, async ({ page }) => { + await setup(page, "/wireguard?server=1") + let releaseOld: (() => void) | undefined + let releaseNew: (() => void) | undefined + const oldGate = new Promise(resolve => { releaseOld = resolve }) + const newGate = new Promise(resolve => { releaseNew = resolve }) + let attempts = 0 + await page.route("**/api/wireguard/revisions?**", async route => { + const current = ++attempts + await (current === 1 ? oldGate : newGate) + if (current === 1 && oldFails) return route.fulfill({ status: 500, json: { error: "Старая ошибка истории" } }) + return route.fulfill({ json: { revisions: current === 1 ? [] : [{ id: "new", serverId: "1", section: "wireguard", source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 9 }] } }) + }) + const open = page.getByRole("button", { name: "История", exact: true }) + const sheet = page.getByRole("dialog", { name: "История WireGuard" }) + await open.click() + await expect.poll(() => attempts).toBe(1) + await expect(sheet.getByRole("status")).toContainText("Загрузка истории") + await page.keyboard.press("Escape") + await expect(sheet).toHaveCount(0) + await open.click() + await expect.poll(() => attempts).toBe(2) + const response = page.waitForResponse(r => r.url().includes("/api/wireguard/revisions?")) + releaseOld!() + await (await response).finished() + await page.waitForTimeout(150) + await expect(sheet.getByRole("status")).toContainText("Загрузка истории") + await expect(sheet.getByRole("alert")).toHaveCount(0) + releaseNew!() + await expect(sheet).toContainText("9 интерфейсов") + await expect(sheet.getByRole("status")).toHaveCount(0) + await expect(sheet.getByRole("button", { name: "Откатить", exact: true })).toBeVisible() + }) +}