From 59f0f82176639bc1a64406a0152930cb11329ffe Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 22:29:46 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BE=D0=B1=D0=BE=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B8=D0=BC=D1=83=D0=BB=D1=8F=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/route-optimizer/page.tsx | 85 +++++++++---------- .../route-optimizer-comm-recs-data-grid.tsx | 22 ++--- .../route-optimizer-full-routes-data-grid.tsx | 6 +- docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-10.md | 4 + tests/e2e/route-optimizer-tables.spec.ts | 4 +- tests/e2e/route-optimizer-trust.spec.ts | 35 ++++++++ 7 files changed, 104 insertions(+), 62 deletions(-) create mode 100644 tests/e2e/route-optimizer-trust.spec.ts diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index c0fc51c1..c7c8bbfa 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -3,12 +3,10 @@ import { useCallback, useEffect, useState, useMemo, useRef } from "react" import Link from "next/link" import { PageHeader } from "@/components/page-header" -import { DataPageCard } from "@/components/data-page-card" import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid" import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid" import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid" import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid" -import { FormToggle } from "@/components/form-kit" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" @@ -41,8 +39,8 @@ import { } from "@/lib/route-optimizer-data" import { RefreshCwIcon, AlertCircleIcon, ArrowRightIcon, - SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon, - ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon, + SettingsIcon, ChevronDownIcon, ChevronUpIcon, + ZapIcon, CheckCircleIcon, NetworkIcon, WifiIcon, MonitorIcon, ServerIcon, InfoIcon, } from "lucide-react" @@ -123,7 +121,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData { const jhExLegs: JhExLeg[] = [] for (const jh of JUMPHOSTS) { for (const ex of EXIT_NODES) { - const base = JE_BASE[`${jh.id}-${ex.id}`] + const base = JE_BASE[`jh${JUMPHOSTS.indexOf(jh) + 1}-ex${EXIT_NODES.indexOf(ex) + 1}`] if (!base) continue jhExLegs.push({ jhId: jh.id, exitId: ex.id, @@ -141,7 +139,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData { const wanJhLegs: WanJhLeg[] = [] for (const wan of home.wans) { for (const jh of JUMPHOSTS) { - const base = HW_BASE[`${wan.id}-${jh.id}`] + const base = HW_BASE[`${wan.id}-jh${JUMPHOSTS.indexOf(jh) + 1}`] if (!base) continue const ping = jitter(base.ping, 3) const dl = Math.min(wan.maxDl, jitter(base.dl, 20)) @@ -231,7 +229,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) { {prob}% + }>{prob} б. ) } @@ -244,15 +242,6 @@ function ConfChip({ conf }: { conf: string }) { return {conf} } -function LossChip({ loss }: { loss: number }) { - if (loss === 0) return 0% - return ( - 1 ? "text-red-500" : "text-amber-500")}> - {loss}% - - ) -} - function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) { return ( applied: Set applying: Set onPin: (k: string) => void onApply: (comm: string, homeId: string) => void + simulation: boolean }) { const [tab, setTab] = useState("wan-matrix") const { home, wanJhLegs, fullRoutes, bestRoute, commRecs } = entry @@ -366,7 +355,7 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, recs={commRecs} homeId={home.id} pinned={pinned} applied={applied} applying={applying} - onPin={onPin} onApply={onApply} + onPin={onPin} onApply={onApply} simulation={simulation} /> )} @@ -405,6 +394,11 @@ function TopologyBar() { // ════════════════════════════════════════════════════════════════════════════ export default function RouteOptimizerPage() { + const { mode, backendUrl } = useDataSource() + return +} + +function RouteOptimizerContent() { const { mode, backendUrl, backendStatus } = useDataSource() /** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */ const useLiveData = mode === "live" @@ -554,13 +548,22 @@ export default function RouteOptimizerPage() { setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n }) } + const simulationTimers = useRef(new Set>()) + const pendingSimulations = useRef(new Set()) + useEffect(() => () => { for (const timer of simulationTimers.current) clearTimeout(timer) }, []) function applyRec(community: string, homeId: string) { + if (useLiveData) return const key = `${homeId}::${community}` + if (pendingSimulations.current.has(key) || applied.has(key)) return + pendingSimulations.current.add(key) setApplying(prev => new Set(prev).add(key)) - setTimeout(() => { + const timer = setTimeout(() => { + pendingSimulations.current.delete(key) + simulationTimers.current.delete(timer) setApplied(prev => new Set(prev).add(key)) setApplying(prev => { const n = new Set(prev); n.delete(key); return n }) }, 1200) + simulationTimers.current.add(timer) } const totalSwitches = useMemo( @@ -606,13 +609,14 @@ export default function RouteOptimizerPage() { { label: "Переключений", value: totalSwitches, - sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально", + sub: totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений", icon: , }, ] }, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches]) function applyAll() { + if (useLiveData) return data?.homes.forEach(h => h.commRecs .filter(r => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`)) @@ -724,9 +728,9 @@ export default function RouteOptimizerPage() { crumbs={[{ label: "Инструменты" }, { label: "Оптимизатор маршрутов" }]} actions={ <> - {totalSwitches > 0 && ( + {!useLiveData && totalSwitches > 0 && ( )} {showSettings && (
@@ -838,28 +845,14 @@ export default function RouteOptimizerPage() {
-

Автоприменение

-
- Применять автоматически - set("autoApply", v)} /> -
- {settings.autoApply && ( - <> - - set("autoApplyIntervalMin", v)} min={5} max={1440} /> - -
- - Изменения каждые {settings.autoApplyIntervalMin} мин -
- - )} +

Автоприменение Community недоступно

+

Настройки расчёта сохраняются в этом браузере. Для сетевых изменений используйте профильные разделы.

@@ -1078,7 +1071,7 @@ export default function RouteOptimizerPage() {

- Оптимизатор работает в VRF {selectedVrf} + Выбран черновой контекст VRF {selectedVrf}
@@ -1090,12 +1083,12 @@ export default function RouteOptimizerPage() { key={entry.home.id} entry={entry} jumpHosts={jumpHostsForCards} - settings={settings} pinned={pinned} applied={applied} applying={applying} onPin={togglePin} onApply={applyRec} + simulation={!useLiveData} /> ))} diff --git a/components/data-grids/route-optimizer-comm-recs-data-grid.tsx b/components/data-grids/route-optimizer-comm-recs-data-grid.tsx index 4d4eddec..6af894ac 100644 --- a/components/data-grids/route-optimizer-comm-recs-data-grid.tsx +++ b/components/data-grids/route-optimizer-comm-recs-data-grid.tsx @@ -41,7 +41,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) { : "bg-muted text-muted-foreground border-border" } > - {prob}% + {prob} б. ) } @@ -53,6 +53,7 @@ interface RouteOptimizerCommRecsDataGridProps { applied: Set applying: Set onPin: (key: string) => void + simulation?: boolean onApply: (community: string, homeId: string) => void } @@ -64,6 +65,7 @@ function RouteOptimizerCommRecsDataGrid({ applying, onPin, onApply, + simulation = false, }: RouteOptimizerCommRecsDataGridProps) { const rows = useMemo( () => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })), @@ -94,7 +96,7 @@ function RouteOptimizerCommRecsDataGrid({ id: "current", header: () => ( - Текущий (WAN → JH → Exit) + Базовый расчёт (WAN → JH → Exit) ), enableSorting: false, @@ -141,7 +143,7 @@ function RouteOptimizerCommRecsDataGrid({ {r.recommended.exit} {r.shouldSwitch && !isPinned && ( - +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}% + +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)} б. )} @@ -153,7 +155,7 @@ function RouteOptimizerCommRecsDataGrid({ id: "probability", header: () => ( - P(тек / рек) + Индекс (баз / рек) ), enableSorting: false, @@ -185,9 +187,9 @@ function RouteOptimizerCommRecsDataGrid({ const r = row.original const pinKey = `${homeId}::${r.community}` const isPinned = pinned.has(pinKey) - const isApplied = applied.has(pinKey) - const isApplying = applying.has(pinKey) - const canApply = r.shouldSwitch && !isPinned && !isApplied + const isApplied = simulation && applied.has(pinKey) + const isApplying = simulation && applying.has(pinKey) + const canApply = simulation && r.shouldSwitch && !isPinned && !isApplied return (
e.stopPropagation()}> {isPinned && } @@ -210,13 +212,13 @@ function RouteOptimizerCommRecsDataGrid({ {isApplying ? ( ) : ( - "Применить" + "Симулировать" )} )} {isApplied && ( - ✓ применено + ✓ симуляция завершена )}
@@ -228,7 +230,7 @@ function RouteOptimizerCommRecsDataGrid({ }, }, ], - [applied, applying, homeId, onApply, onPin, pinned], + [applied, applying, homeId, onApply, onPin, pinned, simulation], ) const table = useTable({ diff --git a/components/data-grids/route-optimizer-full-routes-data-grid.tsx b/components/data-grids/route-optimizer-full-routes-data-grid.tsx index 668edb16..ce20553b 100644 --- a/components/data-grids/route-optimizer-full-routes-data-grid.tsx +++ b/components/data-grids/route-optimizer-full-routes-data-grid.tsx @@ -41,7 +41,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) { : "bg-muted text-muted-foreground border-border" } > - {prob}% + {prob} б. ) } @@ -211,7 +211,7 @@ function RouteOptimizerFullRoutesDataGrid({ id: "prob", accessorKey: "probabilityOptimal", header: ({ column }) => ( - + ), cell: ({ row }) => { const isBest = row.original.id === bestId || row.original._index === 0 @@ -222,7 +222,7 @@ function RouteOptimizerFullRoutesDataGrid({ ) }, meta: { - headerTitle: "P(opt)", + headerTitle: "Индекс", headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"), cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"), }, diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 912b6c42..8f725b95 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.28; рекурсивные маршруты приняты локально, далее оптимизатор | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.29; Community оптимизатора показывает расчёт, далее strict read/OSPF | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.29 — достоверность рекомендаций оптимизатора (2026-09-30) + +- Live Community больше не показывает успех по таймеру: действия симуляции доступны только в демо. Переключение backend/mode размонтирует прежний контекст; таймеры симуляции отменяются, повтор синхронно заблокирован. +- Базовая цепочка подписана как расчётная; проценты вероятности заменены баллами индекса. Указано, что метрики могут включать модельные оценки, а ECMP/RPF/VRF служат локальному просмотру. Автоприменение Community отмечено недоступным; пересчёт настроек подписан явно. Алгоритм live ранжирования и сетевые операции не изменены. +- Демо-матрицы теперь сопоставлены с текущими mock server IDs: прежние jh/ex ID оставляли их пустыми. Проверена успешная симуляция с отдельной подписью. Настройки раскрываются настоящей кнопкой с aria-expanded. +- Проверки: шесть E2E (таблицы/preview, live без фиктивной записи, mock симуляция), TS/lint прошли. Первый прогон выявил div вместо кнопки и несовместимость демо-ID; исправлены. +- Далее 10.30: строгие ответы inventory/rules/probes, ошибки частей и последние данные, epoch/freshness. Затем OSPF preview/apply validation/права/контекст и visual acceptance. Раздел целиком ещё не принят; фактическая текущая Community цепочка и вероятности API отсутствуют. Последняя квота: 66% окна, 41% недели использовано. Локальный коммит без push. + ## Блок 10.28 — адаптивность и локальная приёмка маршрутов (2026-09-30) - Раскрытие группы — отдельная кнопка с aria-expanded, Enter/Space; URL и клик строки сохранены. Форма имеет доступные имена полей и штатный finalFocus Base UI после Escape. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index a8961a06..5f6b77c1 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -164,3 +164,7 @@ server/q/route/table принадлежат экрану; default replace, вы ## 10.28. Локальная приёмка рекурсивных маршрутов 48 основных + 8 source E2E, backend recursive tests, lint/production frontend прошли. 1440/390/320 px, keyboard раскрытие, trap и возврат фокуса, parent-owned scroll; шесть экранов в плане. Неизвестный тип не подменяется EN. ReUI audit consumer-композиции выполнен без изменения vendor/theme. Далее оптимизатор; RouterOS/DB/JWT остаются внешней приёмкой. + +## 10.29. Community: расчёт и симуляция + +Live таймерный успех убран; demo симуляция явно подписана, cleanup/lock сохранены. Модельная базовая цепочка и индекс в баллах обозначены; автоприменение недоступно, ECMP/RPF/VRF локальные. Исправлены mock-ID матриц. 6 E2E, TS/lint прошли. Далее strict чтение и OSPF операции; раздел ещё не принят. diff --git a/tests/e2e/route-optimizer-tables.spec.ts b/tests/e2e/route-optimizer-tables.spec.ts index 5a384c8b..a80f7591 100644 --- a/tests/e2e/route-optimizer-tables.spec.ts +++ b/tests/e2e/route-optimizer-tables.spec.ts @@ -41,8 +41,8 @@ test("Оптимизатор: 12 WAN, все комбинации и закре await expect(routes.locator("tr[data-row-id]")).toHaveCount(5) await page.getByRole("button", { name: "Показать все 12 комбинаций", exact: true }).click() await expect(routes.locator("tr[data-row-id]")).toHaveCount(12) - await routes.getByRole("button", { name: "P(opt)", exact: true }).click() - await routes.getByRole("button", { name: "P(opt)", exact: true }).click() + await routes.getByRole("button", { name: "Индекс", exact: true }).click() + await routes.getByRole("button", { name: "Индекс", exact: true }).click() await expect(routes.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "1-wan12-2-3") await page.getByRole("button", { name: "Свернуть", exact: true }).click() await expect(routes.locator("tr[data-row-id]")).toHaveCount(5) diff --git a/tests/e2e/route-optimizer-trust.spec.ts b/tests/e2e/route-optimizer-trust.spec.ts new file mode 100644 index 00000000..6905831a --- /dev/null +++ b/tests/e2e/route-optimizer-trust.spec.ts @@ -0,0 +1,35 @@ +import {test,expect} from "@playwright/test" +const hosts=(["home-router","jump-host","exit-node"] as const).map((type,index)=>({id:index+1,name:"Роутер "+(index+1),host:"192.0.2."+(index+1),type,site:"TEST",country:"RU",enabled:true,status:"online",latency:10,model:"CHR",...(index===0?{wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}:{})})) +test("Оптимизатор: live рекомендации не подтверждают фиктивную запись",async({page})=>{ + let writes=0 + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{ + const p=new URL(r.request().url()).pathname + if(r.request().method()!=="GET"&&!p.endsWith("/preview"))writes++ + return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?hosts:p==="/api/filters/rules"?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p==="/api/uptime/speed-probes"?{probes:[]}:p.endsWith("/preview")?{interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p.endsWith("/ospf")?{interfaces:[],instances:[],neighbors:[],bfdSessions:[]}:{}}) + }) + await page.goto("/route-optimizer") + await page.getByRole("button",{name:"BGP community (1)",exact:true}).click() + const grid=page.getByRole("table").filter({hasText:"Community"}) + await expect(grid.locator("tr[data-row-id]")).toHaveCount(1) + await expect(grid.getByRole("columnheader",{name:"Базовый расчёт (WAN → JH → Exit)",exact:true})).toBeVisible() + await expect(grid.getByRole("button",{name:"Применить",exact:true})).toHaveCount(0) + await expect(grid.getByRole("button",{name:"Симулировать",exact:true})).toHaveCount(0) + await expect(page.getByRole("button",{name:/Симулировать все/})).toHaveCount(0) + await expect(page.getByText("✓ применено",{exact:true})).toHaveCount(0) + await expect(page.getByText(/Применение Community к устройству недоступно/)).toBeVisible() + await page.getByRole("button",{name:/Настройки оптимизатора/}).click() + await expect(page.getByText("Автоприменение Community недоступно",{exact:true})).toBeVisible() + await expect(page.getByText("Применять автоматически",{exact:true})).toHaveCount(0) + expect(writes).toBe(0) +}) +test("Оптимизатор: демо подтверждает только симуляцию",async({page})=>{ + await page.addInitScript(()=>localStorage.setItem("routerlists:data-source","mock")) + await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{}})) + await page.goto("/route-optimizer") + await page.getByRole("button",{name:/^BGP community/}).last().click() + const simulate=page.getByRole("button",{name:"Симулировать",exact:true}).first() + await expect(simulate).toBeVisible();await simulate.click() + await expect(page.getByText("✓ симуляция завершена",{exact:true}).first()).toBeVisible() + await expect(page.getByText("✓ применено",{exact:true})).toHaveCount(0) +})