From c82434c435829c7bd2c0594ebe7965b2327b6335 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 30 Sep 2026 23:34:05 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=BE=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=B2=20=D0=B0=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/route-optimizer/page.tsx | 46 ++++++++++++------- docs/ux-ui-progress.md | 9 +++- docs/ux-ui-stage-10.md | 4 ++ lib/route-optimizer-response.test.ts | 2 +- .../e2e/route-optimizer-ospf-preview.spec.ts | 28 ++++++++++- 5 files changed, 70 insertions(+), 19 deletions(-) diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index 7d191c62..e2445e53 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -1,7 +1,8 @@ "use client" -import { useCallback, useEffect, useState, useMemo, useRef } from "react" +import { useCallback, useEffect, useState, useMemo, useRef, Suspense } from "react" import Link from "next/link" +import {useSearchParams} from "next/navigation" import {can} from "@/lib/auth" import {ospfCatalogSchema,ospfOptimizeSchema} from "@/lib/ospf-response" import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema,optimizerOspfPreviewSchema,type OptimizerOspfPreview} from "@/lib/route-optimizer-response" @@ -396,11 +397,21 @@ function TopologyBar() { // ════════════════════════════════════════════════════════════════════════════ export default function RouteOptimizerPage() { - const { mode, backendUrl } = useDataSource() - return + return Загрузка контекста оптимизатора…

}>
+} +function RouteOptimizerRoute() { + const {mode,backendUrl}=useDataSource() + const params=useSearchParams() + const requestedServer=params.get("server") + function selectServer(server:string) { + const url=new URL(window.location.href) + url.searchParams.set("server",server) + window.history.pushState(null,"",url.pathname+url.search+url.hash) + } + return } -function RouteOptimizerContent() { +function RouteOptimizerContent({requestedServer,onSelectServer}:{requestedServer:string|null;onSelectServer:(server:string)=>void}) { const { mode, backendUrl, backendStatus } = useDataSource() /** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */ const useLiveData = mode === "live" @@ -449,7 +460,9 @@ function RouteOptimizerContent() { const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst") const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled") const [selectedVrf, setSelectedVrf] = useState("main") - const [ospfServerId, setOspfServerId] = useState("") + const ospfServerId=requestedServer ?? String(liveServers[0]?.id ?? "") + const ospfEligible=/^[1-9]\d*$/.test(ospfServerId) && liveServers.some(server=>String(server.id)===ospfServerId) + const contextError=requestedServer!==null && !loading && !ospfEligible ? "Устройство из ссылки неизвестно или отключено. Выберите доступное устройство." : "" const [ospfApplying, setOspfApplying] = useState(false) const [applyResult, setApplyResult] = useState<{owner:string;serverName:string;optimizedCount:number;receivedAt:string}|null>(null) const [applyError,setApplyError]=useState({owner:"",message:""}) @@ -462,7 +475,7 @@ function RouteOptimizerContent() { const ospfMetaError=metaState.owner===ospfServerId?metaState.error:"" const ospfPreview=previewState.owner===previewKey?previewState.data:null const ospfPreviewError=previewState.owner===previewKey?previewState.error:"" - const ospfPreviewLoading=useLiveData && Boolean(ospfServerId) && (previewState.owner!==previewKey || previewState.pending) + const ospfPreviewLoading=useLiveData && ospfEligible && (previewState.owner!==previewKey || previewState.pending) useEffect(()=>{activeContext.current=previewKey},[previewKey]) const ospfApplyResult=applyResult?.owner===previewKey?applyResult:null const ospfApplyError=applyError.owner===previewKey?applyError.message:"" @@ -502,7 +515,6 @@ function RouteOptimizerContent() { const {jumpHosts,exitNodes} = mapApiServersToTopology(rows) const enabledRows=rows.filter(row=>row.enabled) setLiveServers(enabledRows);setLiveJumpHosts(jumpHosts);setLiveExitNodes(exitNodes) - setOspfServerId(previous=>previous && enabledRows.some(row=>String(row.id)===previous)?previous:String(enabledRows[0]?.id??"")) setData(next);setError("");setReadAt(new Date().toISOString()) } catch (error) { if (current()) setError(error instanceof Error ? error.message : "Ошибка загрузки данных") @@ -639,7 +651,7 @@ function RouteOptimizerContent() { setSettings(prev => ({ ...prev, [k]: v })) useEffect(()=>{ - if(!useLiveData||!ospfServerId)return + if(!useLiveData||!ospfEligible)return let cancelled=false void apiFetch("/api/ospf/catalog").then(raw=>{ if(cancelled)return @@ -655,10 +667,10 @@ function RouteOptimizerContent() { if(!cancelled)setMetaState(previous=>({owner:ospfServerId,data:previous.owner===ospfServerId?previous.data:null,error:error instanceof Error?error.message:"Ошибка каталога OSPF"})) }) return()=>{cancelled=true} - },[apiFetch,liveServers,ospfServerId,useLiveData,previewTick]) + },[apiFetch,liveServers,ospfServerId,ospfEligible,useLiveData,previewTick]) useEffect(()=>{ - if(!useLiveData||!ospfServerId)return + if(!useLiveData||!ospfEligible)return let cancelled=false queueMicrotask(()=>{ if(cancelled)return @@ -673,7 +685,7 @@ function RouteOptimizerContent() { }) }) return()=>{cancelled=true} - },[apiFetch,ospfServerId,settings.pingWeight,useLiveData,previewKey,readAt,previewTick]) + },[apiFetch,ospfServerId,ospfEligible,settings.pingWeight,useLiveData,previewKey,readAt,previewTick]) return (
@@ -834,6 +846,7 @@ function RouteOptimizerContent() {

)} + {useLiveData && contextError &&

{contextError}

} {useLiveData && ( <>
@@ -841,9 +854,10 @@ function RouteOptimizerContent() { - {ospfMetaError ? "Каталог OSPF недостоверен" : ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"} + {contextError ? "Устройство недоступно" : ospfMetaError ? "Каталог OSPF недостоверен" : ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"} + {ospfPreviewLoading ? "расчёт…" diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index fd4f1666..e790b43c 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,18 @@ | 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.35; отсутствующий индекс не подменяется нулём, далее URL/source-контекст | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.36; URL устройства OSPF сохраняется, далее source/provenance | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.36 — URL устройства оптимизатора (2026-09-30) + +- Параметр server восстанавливает OSPF устройство, select обновляет историю, другие параметры/fragment сохраняются. Suspense обеспечивает production render; URL меняет контекст без сброса локальной формы, источник по-прежнему изолирован key(mode, backendUrl). +- Unknown/disabled/malformed/empty explicit server не заменяется первым доступным: доступное имя отсутствует, alert предлагает выбор; preview/metadata запросы и применение заблокированы до явного выбора. Late preview проверен прежним race-тестом. +- 22 E2E preview/apply (6 новых URL сценариев), 3 unit, TS/lint и production frontend прошли. Новый unit fixture из 10.35 уточнён site/model/type после TypeScript проверки. Прежний teardown catalog ECONNREFUSED остаётся известным ограничением теста readonly. +- Далее: source isolation отдельным config, model provenance/fallback отдельно; затем карта/терминал. Вкладки Home/настройки пока не входят в URL. Реальные RouterOS/БД/JWT операции не проверены. + ## Блок 10.35 — неизвестные индексы Community (2026-09-30) - Если полной цепочки нет, оба индекса показывают «—» с доступным названием; отсутствующая база не участвует в вычислении отображаемого выигрыша. Переключение не рекомендуется даже при нулевом пороге. Формула ранжирования полных цепочек не менялась. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 7fa6e6f1..2275c44f 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -192,3 +192,7 @@ Observed selector вместо default cost/disabled; неоднозначные ## 10.35. Отсутствующие индексы Community Без полной цепочки индексы неизвестны, рекомендации нет даже при пороге 0. 7 E2E, 3 unit, TS/lint прошли; расчёт существующих цепочек прежний. Далее URL/source-контекст. + +## 10.36. URL устройства оптимизатора + +server сохраняет выбор/reload/history без потери других параметров. Неизвестный/disabled/malformed explicit контекст не подменяется и не запускает preview/metadata; явный select восстанавливает работу. 22 E2E, 3 unit, TS/lint и production frontend прошли. Далее source/provenance; Home tabs URL пока вне блока. diff --git a/lib/route-optimizer-response.test.ts b/lib/route-optimizer-response.test.ts index 5fb43137..23383082 100644 --- a/lib/route-optimizer-response.test.ts +++ b/lib/route-optimizer-response.test.ts @@ -19,7 +19,7 @@ test("optimizer refuses unknown types, duplicate inventory/WAN and invalid metri import {buildLiveOptimizerData,DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS} from "./route-optimizer-data" test("optimizer does not recommend an absent chain at a zero threshold",()=>{ - const rows=[{...server,wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}] + const rows=[{...server,type:"home-router" as const,site:"TEST",model:"CHR",wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}] const rules=optimizerRulesSchema.parse({rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Service",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}).rulesets const result=buildLiveOptimizerData(rows,rules,{...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,switchThreshold:0}) const recommendation=result.homes[0].commRecs[0] diff --git a/tests/e2e/route-optimizer-ospf-preview.spec.ts b/tests/e2e/route-optimizer-ospf-preview.spec.ts index dd4bbb59..eb067d9b 100644 --- a/tests/e2e/route-optimizer-ospf-preview.spec.ts +++ b/tests/e2e/route-optimizer-ospf-preview.spec.ts @@ -3,7 +3,7 @@ const iface=(name="ether1")=>({id:"i",interface:name,currentCost:10,optimalCost: const preview=(id=1,weight=60,name="ether1")=>({serverId:id,serverName:"Роутер "+id,pingWeight:weight,interfacesTotal:1,changedCount:1,unchangedCount:0,interfaces:[iface(name)],changes:[iface(name)]}) async function base(page:Page){ await page.route("**/health",r=>r.fulfill({json:{ok:true}})) - await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:true,status:null})):p==="/api/auth/config"?{required:false}:p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:preview(Number(p.split("/")[3]),r.request().postDataJSON()?.pingWeight??60)})}) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[1,2,3].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:id!==3,status:null})):p==="/api/auth/config"?{required:false}:p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:preview(Number(p.split("/")[3]),r.request().postDataJSON()?.pingWeight??60)})}) } for(const failure of ["http","foreign","weight","count","missing","duplicate"] as const)test("Оптимизатор: OSPF preview блокирует "+failure,async({page})=>{ await base(page);let failing=true @@ -37,3 +37,29 @@ test("Оптимизатор: поздний preview прежнего устро await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve())))) await expect(grid).toContainText("ether2");await expect(grid).not.toContainText("ether1") }) + +test("Оптимизатор: URL устройства, reload и история",async({page})=>{ + await base(page) + await page.goto("/route-optimizer?server=2&keep=value") + const select=page.getByRole("combobox",{name:"Устройство OSPF",exact:true}) + await expect(select).toHaveValue("2") + await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() + await select.selectOption("1");await expect(page).toHaveURL(/server=1&keep=value/) + await expect(select).toHaveValue("1");await page.reload();await expect(select).toHaveValue("1") + await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() + await page.goBack();await expect(select).toHaveValue("2") + await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() +}) +for(const server of ["999","3","abc","01",""])test("Оптимизатор: недоступный URL не выбирает другой сервер "+server,async({page})=>{ + await base(page);let reads=0 + await page.route("**/ospf/optimize/preview",r=>{reads++;return r.fulfill({json:preview(Number(new URL(r.request().url()).pathname.split("/")[3]))})}) + await page.goto("/route-optimizer?server="+server) + await expect(page.getByRole("alert").filter({hasText:"Устройство из ссылки"})).toBeVisible() + await expect(page.getByRole("combobox",{name:"Устройство OSPF",exact:true})).toHaveValue(server) + await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeDisabled() + await expect(page.getByRole("button",{name:"Обновить preview OSPF",exact:true})).toBeDisabled() + expect(reads).toBe(0) + await page.getByRole("combobox",{name:"Устройство OSPF",exact:true}).selectOption("2") + await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible() + await expect(page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})).toBeEnabled() +})