diff --git a/app/(main)/vxlan/page.tsx b/app/(main)/vxlan/page.tsx index ed3dee71..32f68738 100644 --- a/app/(main)/vxlan/page.tsx +++ b/app/(main)/vxlan/page.tsx @@ -12,10 +12,11 @@ import { OpsPanel } from "@/components/ops-panel" import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar } from "@/components/data-page-toolbar" import { VxlanDataGrid } from "@/components/data-grids/vxlan-data-grid" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Alert, AlertDescription } from "@/components/ui/alert" import { - NetworkIcon, PlusIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon, + NetworkIcon, LayersIcon, RefreshCwIcon, AlertCircleIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { useDataSource } from "@/lib/data-source" @@ -96,12 +97,15 @@ function generateVxlanRsc(t: VxlanTunnel, serverById: Record): s } function ExportSheet({ - open, tunnel, onClose, serverById, + open, tunnel, onClose, serverById, receivedAt, stale, mock, }: { open: boolean tunnel: VxlanTunnel | null onClose: () => void serverById: Record + receivedAt: string | null + stale: boolean + mock: boolean }) { const code = useMemo( () => (tunnel ? generateVxlanRsc(tunnel, serverById) : ""), @@ -113,7 +117,7 @@ function ExportSheet({ open={open} onClose={onClose} title="Экспорт VXLAN" - description="RouterOS 7.x · /interface/vxlan + vteps" + description={mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${receivedAt ? new Date(receivedAt).toLocaleString("ru-RU") : "время получения неизвестно"} · ${stale ? "актуальность не подтверждена" : "последний успешный опрос"}`} formats={[ { id: "rsc", @@ -136,7 +140,9 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, const isLive = mode === "live" const [search, setSearch] = useState("") - const [exportTunnel, setExportTunnel] = useState(null) + const [exportSnapshot, setExportSnapshot] = useState<{ tunnel: VxlanTunnel; servers: Record; at: string | null; stale: boolean; mock: boolean } | null>(null) + const [readReceipt, setReadReceipt] = useState>({}) + const [responseReceipt, setResponseReceipt] = useState(null) const [liveTunnels, setLiveTunnels] = useState([]) const [liveServers, setLiveServers] = useState([]) @@ -151,7 +157,6 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, if (!isLive) return const epoch = ++loadEpoch.current setLoading(true) - setLiveError(null) try { const [tunnelsRes, serversRes] = await Promise.all([ requestJson(backendUrl, "/api/vxlan"), @@ -168,7 +173,11 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, const enabledIds = new Set(nextServers.filter(s => s.enabled).map(s => String(s.id))) setLiveTunnels(previous => [...nextTunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))]) setFailures(nextFailures) + setLiveError(null) 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]))) setLiveServers(nextServers.filter((s) => s.enabled).map(mapBackendServer)) } catch (e) { if (epoch !== loadEpoch.current) return @@ -235,7 +244,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, ) }, [search, scopedTunnels, serverById]) - const failedServerIds = useMemo(() => isLive ? failures.map(f => f.serverId) : [], [isLive, failures]) + const failedServerIds = useMemo(() => !isLive ? [] : liveError ? [...new Set([...liveTunnels.map(t => t.serverId), ...liveServers.map(s => s.id)])] : failures.map(f => f.serverId), [isLive, liveError, liveTunnels, liveServers, failures]) const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId) const upCount = scopedTunnels.filter((t) => t.status === "up").length const vnis = new Set(scopedTunnels.map((t) => t.vni)).size @@ -265,9 +274,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, Обновить - + Только просмотр } /> @@ -301,6 +308,10 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, Ошибка загрузки: {liveError} )} + {isLive &&
+ {effectiveServerId === ALL_SERVERS_ID ? "Последний ответ получен в приложении: " : "Ответ устройства получен в приложении: "} + {(effectiveServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[effectiveServerId]) ? : "неизвестно"} +
} {isLive && loading &&

{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}

} {isLive && liveError && hasLoaded &&

Показаны последние полученные данные; они могут быть устаревшими.

} {selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && ( @@ -320,28 +331,28 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, { id: "tunnels", label: "Туннелей", - value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : scopedTunnels.length, + value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : scopedTunnels.length, icon: , iconClassName: "text-muted-foreground", }, { id: "up", label: "Активных", - value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : upCount, + value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : upCount, icon: , iconClassName: "text-success", }, { id: "vni", label: "Уникальных VNI", - value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : vnis, + value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : vnis, icon: , iconClassName: "text-info", }, { id: "servers", label: "Серверов", - value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size, + value: isLive && (!hasLoaded || !!liveError || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size, icon: , iconClassName: "text-primary", }, @@ -364,14 +375,14 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, search={search} onSearchChange={setSearch} searchPlaceholder="Поиск по имени, VNI, серверу…" - countLabel={`${filtered.length} туннелей`} + countLabel={isLive && !hasLoaded ? "Туннели пока неизвестны" : `${filtered.length} туннелей`} /> - + onExport={tunnel => setExportSnapshot({ tunnel, servers: serverById, at: isLive ? readReceipt[tunnel.serverId] ?? null : null, stale: isLive && (!!liveError || failedServerIds.includes(tunnel.serverId)), mock: !isLive })} + />} @@ -430,16 +441,20 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, setExportTunnel(null)} - serverById={serverById} + open={!!exportSnapshot} + tunnel={exportSnapshot?.tunnel ?? null} + onClose={() => setExportSnapshot(null)} + serverById={exportSnapshot?.servers ?? {}} + receivedAt={exportSnapshot?.at ?? null} + stale={exportSnapshot?.stale ?? false} + mock={exportSnapshot?.mock ?? !isLive} /> ) } function VxlanRoute() { + const { mode, backendUrl } = useDataSource() const params = useSearchParams() const router = useRouter() const serverId = params.get("server") ?? ALL_SERVERS_ID @@ -447,7 +462,7 @@ function VxlanRoute() { const rest = new URLSearchParams(params.toString()) rest.delete("tunnel") const allTunnelsHref = "/vxlan" + (rest.size ? "?" + rest.toString() : "") - return { + return { const next = new URLSearchParams(rest) if (id === ALL_SERVERS_ID) next.delete("server") else next.set("server", id) diff --git a/components/data-grids/vxlan-data-grid.tsx b/components/data-grids/vxlan-data-grid.tsx index ab683352..e47cc2e6 100644 --- a/components/data-grids/vxlan-data-grid.tsx +++ b/components/data-grids/vxlan-data-grid.tsx @@ -14,7 +14,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell" @@ -29,9 +28,6 @@ import { CodeXmlIcon, MoreHorizontalIcon, NetworkIcon, - PencilIcon, - PowerIcon, - Trash2Icon, } from "lucide-react" interface VxlanDataGridProps { @@ -227,20 +223,6 @@ function VxlanDataGrid({ tunnels, servers, onExport, failedServerIds = [] }: Vxl Экспорт .rsc - - - Редактировать - - - - - {tunnel.enabled ? "Отключить" : "Включить"} - - - - - Удалить - @@ -270,7 +252,7 @@ function VxlanDataGrid({ tunnels, servers, onExport, failedServerIds = [] }: Vxl } title="Нет VXLAN-туннелей" - description="Добавьте туннель или измените поиск" + description="Измените поиск или проверьте конфигурацию VXLAN в RouterOS" className="border-0 py-16" /> ) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 22f94bb0..5bf6f3f2 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.52 — IPsec/GRE/VXLAN, Firewall и операции Filters | +| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.53 — IPsec/GRE/VXLAN, Firewall и операции Filters | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 9.53 — достоверность VXLAN и режим просмотра (2026-09-30) + +- Backend VXLAN предоставляет GET; удалены активные кнопки без обработчиков (создание/редактирование/включение/удаление). Экран явно отмечен «Только просмотр»; доступны опрос и экспорт .rsc. Новые сетевые write-операции в этом блоке не вводились. +- При полном отказе сохранённые строки получают неизвестный статус, KPI не выдают старый UP за актуальное значение. Первоначальная ошибка не показывает ложную пустую таблицу. Ошибка сохраняется до успешного повтора, время успешного чтения — по устройствам; частичный отказ не обновляет дату неответившего устройства. Даты клиентские, общий ответ не гарантирует успешный опрос каждого устройства. +- Экспорт фиксирует строку, metadata сервера, дату и признак неизвестной актуальности на момент открытия; обновление таблицы не меняет открытый код. Mock-экспорт подписан отдельно. Смена backend/mode отделяет состояние, панели и даты; URL-контекст сохранён. +- После последних изменений прошли 17 обычных E2E VXLAN и 4 отдельных E2E смены источника. TypeScript и lint чистые. Backend не менялся; последний frontend build — 9.52, backend — 9.44. +- Далее — точность/экранирование .rsc VXLAN и валидация согласованности ответа, затем ошибки общего GRE-каталога и общая регрессия этапа 9. Реальная RouterOS-приёмка/полные права/DB/JWT не выполнены. Локальный коммит без push. + ## Блок 9.52 — клавиатура и экраны IPsec (2026-09-30) - Два E2E проверили ПК 1440×1000 и телефон 390×844: выбор устройства на телефоне, открытие клиента клавиатурой, сохранение через Enter с единственным PATCH в правильный serverId, отсутствие горизонтального переполнения документа, границы формы и серверные действия. Шесть PNG сохранены и просмотрены. Имена клиентов получают title для обрезанного текста. diff --git a/docs/ux-ui-stage-9.md b/docs/ux-ui-stage-9.md index 20c4a92a..c4db18d7 100644 --- a/docs/ux-ui-stage-9.md +++ b/docs/ux-ui-stage-9.md @@ -284,3 +284,11 @@ Live-копирование читает актуальный целевой rul | [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Клиенты](ux-ui/screens/ipsec-desktop.png) | [Клиенты](ux-ui/screens/ipsec-mobile.png) | | [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp) | [Форма клиента](ux-ui/screens/ipsec-client-desktop.png) | [Форма клиента](ux-ui/screens/ipsec-client-mobile.png) | | [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Серверы](ux-ui/screens/ipsec-servers-desktop.png) | [Сервер](ux-ui/screens/ipsec-servers-mobile.png) | + +## Блок 9.53 — достоверность VXLAN и режим просмотра (2026-09-30) + +- Backend VXLAN предоставляет GET; удалены активные кнопки без обработчиков (создание/редактирование/включение/удаление). Экран явно отмечен «Только просмотр»; доступны опрос и экспорт .rsc. Новые сетевые write-операции в этом блоке не вводились. +- При полном отказе сохранённые строки получают неизвестный статус, KPI не выдают старый UP за актуальное значение. Первоначальная ошибка не показывает ложную пустую таблицу. Ошибка сохраняется до успешного повтора, время успешного чтения — по устройствам; частичный отказ не обновляет дату неответившего устройства. Даты клиентские, общий ответ не гарантирует успешный опрос каждого устройства. +- Экспорт фиксирует строку, metadata сервера, дату и признак неизвестной актуальности на момент открытия; обновление таблицы не меняет открытый код. Mock-экспорт подписан отдельно. Смена backend/mode отделяет состояние, панели и даты; URL-контекст сохранён. +- После последних изменений прошли 17 обычных E2E VXLAN и 4 отдельных E2E смены источника. TypeScript и lint чистые. Backend не менялся; последний frontend build — 9.52, backend — 9.44. +- Далее — точность/экранирование .rsc VXLAN и валидация согласованности ответа, затем ошибки общего GRE-каталога и общая регрессия этапа 9. Реальная RouterOS-приёмка/полные права/DB/JWT не выполнены. Локальный коммит без push. diff --git a/playwright.config.ts b/playwright.config.ts index 57864be9..367d5a7c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts"], + testIgnore: ["**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/playwright.vxlan-context.config.ts b/playwright.vxlan-context.config.ts new file mode 100644 index 00000000..7d7561ab --- /dev/null +++ b/playwright.vxlan-context.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test" +import base from "./playwright.config" + +const server = base.webServer +if (!server || Array.isArray(server)) throw new Error("Ожидается один локальный сервер") +export default defineConfig({ + ...base, + use: { ...base.use, baseURL: "http://127.0.0.1:3101" }, + testIgnore: [], + testMatch: "vxlan-context.spec.ts", + webServer: { ...server, command: "node node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3101", url: "http://127.0.0.1:3101", env: { ...server.env, NEXT_PUBLIC_BACKEND_URL: "local" } }, +}) diff --git a/tests/e2e/vxlan-context.spec.ts b/tests/e2e/vxlan-context.spec.ts new file mode 100644 index 00000000..1c8efb9c --- /dev/null +++ b/tests/e2e/vxlan-context.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test" +const headers = { "access-control-allow-origin": "*", "access-control-allow-methods": "GET,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" } +const tunnel = (port: string) => ({ id: "*1", name: port === "8101" ? "Новый VXLAN" : "Старый VXLAN", serverId: "1", vni: port === "8101" ? 200 : 100, port: 0, dstPort: 8472, vtepIp: "192.0.2.1", remoteVteps: [], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" }) + +for (const change of ["backend", "mode"]) for (const fails of [false, true]) { + test(`VXLAN: позднее чтение после смены ${change}/${fails}`, async ({ page }) => { + await page.route("**/health", r => r.fulfill({ headers, json: { ok: true } })) + await page.route("**/api/**", r => { + const url = new URL(r.request().url()), path = url.pathname + return r.fulfill({ headers, json: path === "/api/vxlan" ? { tunnels: [tunnel(url.port)] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/settings") + await page.getByRole("link", { name: /^VXLAN/ }).click() + await expect(page.locator("tr[data-row-id]")).toContainText("Старый VXLAN") + let release: (() => void) | undefined, started = false + const gate = new Promise(resolve => { release = resolve }) + await page.route("**/api/vxlan", async r => { + if (new URL(r.request().url()).port !== "8000") return r.fallback() + started = true + await gate + return r.fulfill(fails ? { headers, status: 503, json: { error: "Старая ошибка VXLAN" } } : { headers, json: { tunnels: [] } }) + }) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect.poll(() => started).toBe(true) + await page.goBack() + await expect(page).toHaveURL(/settings/) + if (change === "backend") { + const input = page.getByPlaceholder("http://localhost:8000") + await input.fill("http://localhost:8101") + await input.press("Enter") + } else await page.getByRole("button", { name: "Моковые", exact: true }).click() + await page.getByRole("link", { name: /^VXLAN/ }).click() + if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый VXLAN") + else await expect(page.getByText("Моковые данные", { exact: true })).toBeVisible() + const response = page.waitForResponse(r => r.url().includes(":8000/api/vxlan")) + release!() + await (await response).finished() + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + if (change === "backend") await expect(page.locator("tr[data-row-id]")).toContainText("Новый VXLAN") + await expect(page.getByText("Ошибка загрузки:", { exact: false })).toHaveCount(0) + }) +} diff --git a/tests/e2e/vxlan-freshness.spec.ts b/tests/e2e/vxlan-freshness.spec.ts new file mode 100644 index 00000000..80dca3d8 --- /dev/null +++ b/tests/e2e/vxlan-freshness.spec.ts @@ -0,0 +1,54 @@ +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, vni: number) => ({ id: "*" + id, serverId: id, name: "vxlan-" + id, vni, port: 0, dstPort: 8472, vtepIp: "192.0.2." + id, remoteVteps: ["198.51.100.1"], l2mtu: 1500, arpProxy: false, macLearning: true, comment: "", enabled: true, status: "up" }) + +test("VXLAN: свежесть, неизвестный статус и честный режим просмотра", 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/vxlan" && phase === 2) return r.fulfill({ status: 503, json: { error: "Опрос VXLAN недоступен" } }) + return r.fulfill({ json: path === "/api/vxlan" ? { tunnels: phase === 1 ? [tunnel("1", 100)] : [tunnel("1", 100), tunnel("2", phase === 3 ? 300 : 200)], failures: phase === 1 ? [{ serverId: "2", error: "Таймаут тест" }] : [] } + : path === "/api/servers" ? devices : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/vxlan?server=2") + const rows = page.locator("tr[data-row-id]") + await expect(rows).toHaveCount(1) + await expect(page.getByText("Только просмотр", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Новый VXLAN" })).toHaveCount(0) + const time = page.getByRole("group", { name: "Свежесть VXLAN" }).locator("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: "Действия: vxlan-2" }).click() + for (const name of ["Редактировать", "Отключить", "Удалить"]) await expect(page.getByRole("menuitem", { name, exact: true })).toHaveCount(0) + await page.getByRole("menuitem", { name: "Экспорт .rsc", exact: true }).click() + const dialog = page.getByRole("dialog") + await expect(dialog).toContainText("актуальность не подтверждена") + await expect(dialog).toContainText("vni=200") + phase = 3 + const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true }) + await refresh.evaluate(el => (el as HTMLButtonElement).click()) + await expect(rows).toContainText("300") + await expect(dialog).toContainText("vni=200") + await expect(dialog).toContainText("актуальность не подтверждена") + 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.getByText("Ошибка загрузки: Опрос VXLAN недоступен", { exact: true })).toBeVisible() + await expect(rows).toHaveCount(1) + await expect(rows).toContainText("Неизвестно") + await expect(page.getByLabel("Сводка VXLAN")).toContainText("—") + await expect(time).toHaveAttribute("datetime", after!) + expect(writes).toBe(0) +})