From a21252fb02bc81c8004a56d577305a699356ee0d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 16:00:48 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20GRE=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BB=D1=8F=D1=82=D1=8C=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/gre/page.tsx | 38 ++++++++++++++++++++++----- docs/ux-ui-progress.md | 9 ++++++- docs/ux-ui-stage-9.md | 8 ++++++ tests/e2e/gre-freshness.spec.ts | 46 +++++++++++++++++++++++++++++++++ tests/e2e/gre-readiness.spec.ts | 1 + 5 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/gre-freshness.spec.ts diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index 2ff0349e..f314d078 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -231,6 +231,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const [failures, setFailures] = useState["failures"]>([]) const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures]) const [hasLoaded, setHasLoaded] = useState(false) + const [readReceipt, setReadReceipt] = useState>({}) + const [responseReceipt, setResponseReceipt] = useState(null) + const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null) const loadEpoch = useRef(0) const mounted = useRef(true) const operationLock = useRef(false) @@ -264,6 +267,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const [historyRestoring, setHistoryRestoring] = useState(false) const [poolOpen, setPoolOpen] = useState(false) const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) + const [exportContext, setExportContext] = useState<{ servers: Record; at: string | null; stale: boolean; mock: boolean } | null>(null) const [tForm, setTForm] = useState(defaultTunnelForm) const [pForm, setPForm] = useState(defaultPoolForm) @@ -289,6 +293,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on setLiveTunnels(previous => [...response.data.tunnels, ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))]) setFailures(response.data.failures) setHasLoaded(true) + const at = new Date().toISOString() + setResponseReceipt(at) + setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1]))) setDataError(null) setLiveStale(false) } catch (e) { @@ -400,6 +407,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on ) if (!mounted.current) return if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление") + setWriteReceipt({ serverId: historyServerId, at: new Date().toISOString() }) toast.success("Версия применена на роутер") await loadLive(true) if (mounted.current) await loadRevisions() @@ -464,6 +472,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on }) if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение туннеля") + setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() }) toast.success(`Туннель ${tForm.name} обновлён`) } else { const result = await apiFetch("/api/gre/tunnels", { @@ -479,6 +488,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on "enabled" in result && result.enabled === tForm.enabled) ) if (!confirmed) throw new Error("Сервер не подтвердил создание туннеля") + setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() }) toast.success(`Туннель ${tForm.name} создан`) } setTunnelOpen(false) @@ -510,6 +520,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on }) if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение туннеля") + setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() }) toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`) await loadLive(true) } catch (err) { @@ -533,6 +544,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on }) if (!mounted.current) return if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление туннеля") + setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() }) toast.success(`Удалён ${t.name}`) setPendingDelete(null) await loadLive(true) @@ -615,9 +627,16 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on { value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount }, ] + function openCodePreview(t: GreTunnel) { + setCodePreviewTunnel(t) + setExportContext({ servers: serverById, at: readReceipt[t.serverId] ?? null, stale: isLive && (!hasLoaded || !!dataError || failedServerIds.includes(t.serverId)), mock: !isLive }) + } + const scopeReceipt = selectedServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[selectedServerId] + const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU") + const greExportCode = useMemo( - () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""), - [codePreviewTunnel, serverById], + () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, exportContext?.servers ?? {}) : ""), + [codePreviewTunnel, exportContext], ) return ( @@ -744,6 +763,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on ]} /> +
+ {isLive ? selectedServerId === ALL_SERVERS_ID ? "Ответ каталога: " : "Опрос устройства: " : "Моковые данные · "}{isLive ? scopeReceipt ? : "неизвестно" : "не получены с RouterOS"} + {isLive && writeReceipt && (selectedServerId === ALL_SERVERS_ID || writeReceipt.serverId === selectedServerId) && API подтвердил изменение · {serverById[writeReceipt.serverId]?.name ?? writeReceipt.serverId}: } +
+ {toggleStatus &&

{toggleStatus}

} {toggleError &&

{toggleError}

} @@ -780,7 +804,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on tunnels={filtered} servers={displayServers} pools={displayPools} - onCodePreview={setCodePreviewTunnel} + onCodePreview={openCodePreview} onEdit={openEditTunnel} onToggle={(t) => { void toggleTunnel(t) }} onDelete={setPendingDelete} @@ -864,7 +888,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on open={!!codePreviewTunnel} onClose={() => setCodePreviewTunnel(null)} title={codePreviewTunnel?.name ?? "GRE"} - description="Команды RouterOS 7.20+ для создания туннеля" + description={exportContext?.mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${exportContext?.at ? formatReceipt(exportContext.at) : "время получения неизвестно"} · ${exportContext?.stale ? "актуальность не подтверждена" : "последний успешный опрос"}`} formats={[ { id: "rsc", @@ -877,11 +901,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on codePreviewTunnel ? (
- - {greStatusMeta(codePreviewTunnel.status).label} + + {exportContext?.stale ? "Неизвестно" : greStatusMeta(codePreviewTunnel.status).label} · - {serverById[codePreviewTunnel.serverId]?.name} + {exportContext?.servers[codePreviewTunnel.serverId]?.name} · {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1461b652..970b406a 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -25,12 +25,19 @@ | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | -| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.59 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.60 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.60 — свежесть и снимок экспорта GRE (2026-09-30) + +- Успешный ответ каталога и опрос каждого включённого устройства имеют отдельные отметки получения браузером. При failures отметка устройства сохраняется, при глобальной ошибке не обновляется. Подтверждение API записи/восстановления показано отдельно от опроса. Эти отметки не объявляются временем измерения RouterOS. +- Экспорт фиксирует туннель, имена устройств, время и актуальность при открытии. Фоновый опрос не меняет уже открытый код/контекст; устаревший статус — неизвестный, mock явно подписан. Source/URL смена закрывает прежний снимок. +- 11 E2E прошли: часы/частичный и полный отказ/восстановление/экспорт плюс readiness и данные. TypeScript и lint экрана чистые. +- Далее — корректность самого GRE .rsc и параметров формы, реальные возможности live-пулов и desktop/mobile. Локальный коммит без push. + ## Блок 9.59 — готовность и изоляция операций GRE (2026-09-30) - Запись разрешена только после успешного чтения inventory/GRE на включённом устройстве без его failure. Частичный отказ не блокирует исправное устройство; меню и выбор сервера показывают недоступность отказавшего/выключенного. Проверка повторяется непосредственно перед запросом. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 3c22789c..0aa36c51 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -337,3 +337,11 @@ Live-копирование читает актуальный целевой rul - Проверено 25 различных E2E: 11 истории, три формы, пять readiness/delete/toggle и шесть поздних ответов в отдельной source-конфигурации. TypeScript и lint чистые. Проверки обнаружили и устранили незавершённое освобождение lock в delete/toggle; повтор прошёл. - Далее — временные отметки и экспорт GRE, достоверность live-пулов, desktop/mobile приёмка. JWT/RouterOS остаются внешней приёмкой. Локальный коммит без push. + +## Блок 9.60 — свежесть и снимок экспорта GRE (2026-09-30) + +- Успешный ответ каталога и опрос каждого включённого устройства имеют отдельные отметки получения браузером. При failures отметка устройства сохраняется, при глобальной ошибке не обновляется. Подтверждение API записи/восстановления показано отдельно от опроса. Эти отметки не объявляются временем измерения RouterOS. +- Экспорт фиксирует туннель, имена устройств, время и актуальность при открытии. Фоновый опрос не меняет уже открытый код/контекст; устаревший статус — неизвестный, mock явно подписан. Source/URL смена закрывает прежний снимок. +- 11 E2E прошли: часы/частичный и полный отказ/восстановление/экспорт плюс readiness и данные. TypeScript и lint экрана чистые. +- Далее — корректность самого GRE .rsc и параметров формы, реальные возможности live-пулов и desktop/mobile. Локальный коммит без push. + diff --git a/tests/e2e/gre-freshness.spec.ts b/tests/e2e/gre-freshness.spec.ts new file mode 100644 index 00000000..42a660b0 --- /dev/null +++ b/tests/e2e/gre-freshness.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test" +const devices = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU" })) +const tunnel = (id: string, remoteAddress: string) => ({ id: "*" + id, serverId: id, name: "gre-" + id, localAddress: "192.0.2.1", remoteAddress, localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }) + +test("GRE: отметка чтения и закреплённый экспорт при частичном отказе", async ({ page }) => { + await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") }) + let phase = 0, writes = 0 + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (r.request().method() !== "GET") writes++ + if (path === "/api/gre/tunnels" && phase === 2) return r.fulfill({ status: 503, json: { error: "Опрос GRE недоступен" } }) + return r.fulfill({ json: path === "/api/gre/tunnels" ? { tunnels: phase === 1 ? [tunnel("1", "198.51.100.1")] : [tunnel("1", "198.51.100.1"), tunnel("2", phase === 3 ? "198.51.100.33" : "198.51.100.22")], failures: phase === 1 ? [{ serverId: "2", error: "Таймаут тест" }] : [] } : path === "/api/servers" ? devices : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/gre?server=2") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + const time = page.getByRole("group", { name: "Свежесть GRE" }).locator('[data-source-time="read"] time') + const first = await time.getAttribute("datetime") + await page.clock.fastForward(60_000) + phase = 1 + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Таймаут тест" })).toBeVisible() + await expect(rows).toContainText("Неизвестно") + await expect(time).toHaveAttribute("datetime", first!) + await page.getByRole("button", { name: "Действия: gre-2" }).click() + await page.getByRole("menuitem", { name: "Просмотр кода", exact: true }).click() + const dialog = page.getByRole("dialog") + await expect(dialog).toContainText("актуальность не подтверждена") + await expect(dialog).toContainText("198.51.100.22") + phase = 3 + const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true }) + await refresh.evaluate(el => (el as HTMLButtonElement).click()) + await expect(rows).toContainText("198.51.100.33") + await expect(dialog).toContainText("198.51.100.22") + await expect(dialog).not.toContainText("198.51.100.33") + await page.keyboard.press("Escape") + await expect(time).not.toHaveAttribute("datetime", first!) + const after = await time.getAttribute("datetime") + phase = 2 + await page.clock.fastForward(60_000) + await refresh.click() + await expect(page.getByRole("alert").filter({ hasText: "Опрос GRE недоступен" })).toBeVisible() + await expect(time).toHaveAttribute("datetime", after!) + expect(writes).toBe(0) +}) diff --git a/tests/e2e/gre-readiness.spec.ts b/tests/e2e/gre-readiness.spec.ts index b0a954e9..750100c3 100644 --- a/tests/e2e/gre-readiness.spec.ts +++ b/tests/e2e/gre-readiness.spec.ts @@ -58,6 +58,7 @@ test("GRE: общий замок до опроса после подтвержд expect(reads).toBe(before) release!() await expect(dialog).toHaveCount(0) + await expect(page.getByRole("group", { name: "Свежесть GRE" }).locator('[data-source-time="write"] time')).toHaveCount(1) await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить GRE" })).toContainText("Опрос после удаления недоступен") await expect(page.locator("tr[data-row-id]")).toHaveCount(1) await page.getByRole("button", { name: "Действия: GRE 1" }).click()