fix(ui): сохранять контекст карты сети в ссылке
This commit is contained in:
1 parent
0cdb43a29d
commit
8bc4f22af7
6 files changed
+114
-24
No files matched your search
@@ -1,6 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state"
|
||||||
|
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
servers as mockServers,
|
servers as mockServers,
|
||||||
@@ -319,14 +322,7 @@ const MOCK_COUNTRY_SERVICE_GROUPS: FlowMapCountryServiceGroup[] = [
|
|||||||
const DEST_MODE_KEY = "mm-network-map-dest-mode"
|
const DEST_MODE_KEY = "mm-network-map-dest-mode"
|
||||||
type DestMode = "services" | "countries"
|
type DestMode = "services" | "countries"
|
||||||
|
|
||||||
function readDestMode(): DestMode {
|
|
||||||
if (typeof window === "undefined") return "services"
|
|
||||||
try {
|
|
||||||
return sessionStorage.getItem(DEST_MODE_KEY) === "countries" ? "countries" : "services"
|
|
||||||
} catch {
|
|
||||||
return "services"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string {
|
function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string {
|
||||||
if (!node) return fallback
|
if (!node) return fallback
|
||||||
@@ -475,7 +471,7 @@ function edgeBadgePosition(
|
|||||||
|
|
||||||
// ─── Filter ───────────────────────────────────────────────────────────────────
|
// ─── Filter ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type FilterKey = "all" | "online" | "degraded" | "offline" | "unknown" | "jump-host" | "exit-node" | "home-router"
|
type FilterKey = NetworkMapFilter
|
||||||
|
|
||||||
function greEdgeKey(e: GreMapEdge): string {
|
function greEdgeKey(e: GreMapEdge): string {
|
||||||
const la = (e.tunnel.localAddress ?? "").trim()
|
const la = (e.tunnel.localAddress ?? "").trim()
|
||||||
@@ -1138,10 +1134,23 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
export default function NetworkMapPage() {
|
export default function NetworkMapPage() {
|
||||||
|
return <Suspense fallback={<p role="status">Загрузка контекста карты…</p>}><NetworkMapRoute /></Suspense>
|
||||||
|
}
|
||||||
|
function NetworkMapRoute(){
|
||||||
const {mode,backendUrl}=useDataSource()
|
const {mode,backendUrl}=useDataSource()
|
||||||
|
const params=useSearchParams()
|
||||||
|
const state=readNetworkMapUrl(new URLSearchParams(params.toString()))
|
||||||
|
if(state.error)return <div><PageHeader crumbs={[{label:"Обзор"},{label:"Карта сети"}]} /><Alert variant="destructive"><AlertTitle>Контекст карты недоступен</AlertTitle><AlertDescription>{state.error}<Button type="button" variant="outline" onClick={()=>window.history.replaceState(null,"",patchNetworkMapUrl(new URLSearchParams(window.location.search),{server:null,q:null,filter:null,dest:null,wan:null})+window.location.hash)}>Сбросить контекст карты</Button></AlertDescription></Alert></div>
|
||||||
return <NetworkMapContent key={JSON.stringify([mode,backendUrl])} />
|
return <NetworkMapContent key={JSON.stringify([mode,backendUrl])} />
|
||||||
}
|
}
|
||||||
function NetworkMapContent() {
|
function NetworkMapContent() {
|
||||||
|
const params=useSearchParams()
|
||||||
|
const serializedParams=params.toString()
|
||||||
|
const urlState=useMemo(()=>readNetworkMapUrl(new URLSearchParams(serializedParams)),[serializedParams])
|
||||||
|
const updateUrl=useCallback((values:Record<string,string|null>,replace=false)=>{
|
||||||
|
const href=patchNetworkMapUrl(new URLSearchParams(window.location.search),values)+window.location.hash
|
||||||
|
if(href!==window.location.pathname+window.location.search+window.location.hash)(replace?window.history.replaceState:window.history.pushState).call(window.history,null,"",href)
|
||||||
|
},[])
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const useLiveData = mode === "live"
|
const useLiveData = mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
@@ -1298,17 +1307,17 @@ function NetworkMapContent() {
|
|||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||||
const [selectedStored, setSelected] = useState<Server | null>(null)
|
const selected=urlState.server?mapServers.find(server=>server.id===urlState.server)??null:null
|
||||||
const selected=selectedStored?mapServers.find(server=>server.id===selectedStored.id)??null:null
|
const setSelected=useCallback((value:React.SetStateAction<Server|null>)=>{
|
||||||
|
const next=typeof value==="function"?value(selected):value
|
||||||
|
updateUrl({server:next?.id??null,wan:null})
|
||||||
|
},[selected,updateUrl])
|
||||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||||
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
|
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
|
||||||
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
|
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
|
||||||
const [destMode, setDestModeState] = useState<DestMode>("services")
|
const destMode=urlState.dest
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => setDestModeState(readDestMode()))
|
|
||||||
}, [])
|
|
||||||
function setDestMode(mode: DestMode) {
|
function setDestMode(mode: DestMode) {
|
||||||
setDestModeState(mode)
|
updateUrl({dest:mode==="services"?null:mode})
|
||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
setExpandedCountryId(null)
|
setExpandedCountryId(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
@@ -1337,7 +1346,8 @@ function NetworkMapContent() {
|
|||||||
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
|
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
|
||||||
: null
|
: null
|
||||||
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
||||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
const selWanIdx=urlState.wan
|
||||||
|
function setSelWanIdx(value:React.SetStateAction<number|null>){const next=typeof value==="function"?value(selWanIdx):value;updateUrl({wan:next==null?null:String(next)})}
|
||||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||||
|
|
||||||
// ── Node positions (overrides POS defaults) ─────────────────────────────────
|
// ── Node positions (overrides POS defaults) ─────────────────────────────────
|
||||||
@@ -1367,8 +1377,10 @@ function NetworkMapContent() {
|
|||||||
const [selectedGreStored, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
|
const [selectedGreStored, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
|
||||||
|
|
||||||
// ── Toggles ─────────────────────────────────────────────────────────────────
|
// ── Toggles ─────────────────────────────────────────────────────────────────
|
||||||
const [filter, setFilter] = useState<FilterKey>("all")
|
const filter=urlState.filter
|
||||||
const [search, setSearch] = useState("")
|
const search=urlState.search
|
||||||
|
const setFilter=(value:FilterKey)=>updateUrl({filter:value==="all"?null:value})
|
||||||
|
const setSearch=(value:string)=>updateUrl({q:value||null},true)
|
||||||
const [showPingBadges, setShowPingBadges] = useState(true)
|
const [showPingBadges, setShowPingBadges] = useState(true)
|
||||||
const [showNetflow, setShowNetflow] = useState(true)
|
const [showNetflow, setShowNetflow] = useState(true)
|
||||||
const [showServices, setShowServices] = useState(true)
|
const [showServices, setShowServices] = useState(true)
|
||||||
@@ -1775,7 +1787,7 @@ function NetworkMapContent() {
|
|||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setSelWanIdx(null)
|
updateUrl({wan:null})
|
||||||
setSelectedGreEdge(null)
|
setSelectedGreEdge(null)
|
||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
@@ -1789,7 +1801,7 @@ function NetworkMapContent() {
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
}, [applyZoomCenter,fitView])
|
}, [applyZoomCenter,fitView,setSelected,updateUrl])
|
||||||
|
|
||||||
// ── Drag ─────────────────────────────────────────────────────────────────
|
// ── Drag ─────────────────────────────────────────────────────────────────
|
||||||
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
@@ -2051,6 +2063,7 @@ function NetworkMapContent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{urlState.server&&((hasSnapshot||!useLiveData)&&!selected||selected&&selWanIdx!==null&&!selected.wanUplinks?.[selWanIdx])&&<Alert variant="destructive"><AlertTitle>Объект карты недоступен</AlertTitle><AlertDescription>{!selected?"Устройство "+urlState.server+" отсутствует в текущем снимке.":"Запрошенный WAN отсутствует у выбранного устройства."}<Button type="button" variant="outline" onClick={()=>updateUrl({server:null,wan:null})}>Сбросить выбранный объект</Button></AlertDescription></Alert>}
|
||||||
{/* ── Toolbar ── */}
|
{/* ── Toolbar ── */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/60 shrink-0 flex-wrap">
|
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/60 shrink-0 flex-wrap">
|
||||||
|
|
||||||
@@ -2075,7 +2088,7 @@ function NetworkMapContent() {
|
|||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Поиск узла…"
|
aria-label="Поиск узла" maxLength={256} placeholder="Поиск узла…"
|
||||||
className="h-7 pl-6 pr-3 text-xs rounded-md border border-border bg-muted/40
|
className="h-7 pl-6 pr-3 text-xs rounded-md border border-border bg-muted/40
|
||||||
placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring w-44"
|
placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring w-44"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,11 +26,18 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.50; оптимизатор принят локально, карта сети в работе |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.51; оптимизатор принят локально, карта сети в работе |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.51 — URL-контекст карты (2026-10-01)
|
||||||
|
|
||||||
|
- URL сохраняет server/WAN, status filter, q и режим services/countries. Перезагрузка и история восстанавливают детали/срез; поиск заменяет текущий history entry. Посторонние query/hash сохраняются. Выбор GRE/сервиса пока локальный, pan/zoom/drag также локальные.
|
||||||
|
- Строго проверяются дубли, длины и enum. Неверный URL показывает ошибку до чтения inventory, сброс очищает только поля карты. Отсутствующий server или WAN явно недоступен; первый другой объект не выбирается. Nullable server status совместим с unknown filter.
|
||||||
|
- 2 URL unit, 11 E2E URL/details (7 URL повторены после исправления тестового encoded-space matcher), TypeScript и lint прошли. В production ещё требуется общий build после адаптивной композиции.
|
||||||
|
- Далее ReUI Frame, панели и интерактивные SVG controls: keyboard/focus, телефон, reduced motion, скриншоты и локальная приёмка карты.
|
||||||
|
|
||||||
## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01)
|
## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01)
|
||||||
|
|
||||||
- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
|
- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
|
||||||
|
|||||||
@@ -276,3 +276,11 @@ Shared контракт и backend observed исходные значения/д
|
|||||||
- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
|
- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
|
||||||
- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты.
|
- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.51 — URL-контекст карты (2026-10-01)
|
||||||
|
|
||||||
|
- URL сохраняет server/WAN, status filter, q и режим services/countries. Перезагрузка и история восстанавливают детали/срез; поиск заменяет текущий history entry. Посторонние query/hash сохраняются. Выбор GRE/сервиса пока локальный, pan/zoom/drag также локальные.
|
||||||
|
- Строго проверяются дубли, длины и enum. Неверный URL показывает ошибку до чтения inventory, сброс очищает только поля карты. Отсутствующий server или WAN явно недоступен; первый другой объект не выбирается. Nullable server status совместим с unknown filter.
|
||||||
|
- 2 URL unit, 11 E2E URL/details (7 URL повторены после исправления тестового encoded-space matcher), TypeScript и lint прошли. В production ещё требуется общий build после адаптивной композиции.
|
||||||
|
- Далее ReUI Frame, панели и интерактивные SVG controls: keyboard/focus, телефон, reduced motion, скриншоты и локальная приёмка карты.
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {readNetworkMapUrl,patchNetworkMapUrl} from "./network-map-url-state"
|
||||||
|
test("map URL restores exact context and keeps unrelated parameters",()=>{
|
||||||
|
const state=readNetworkMapUrl(new URLSearchParams("server=srv1&wan=0&filter=unknown&q=site&dest=countries"))
|
||||||
|
assert.deepEqual(state,{server:"srv1",wan:0,filter:"unknown",search:"site",dest:"countries",error:null})
|
||||||
|
assert.equal(patchNetworkMapUrl(new URLSearchParams("tag=keep&server=1&wan=0"),{server:null,wan:null}),"/network-map?tag=keep")
|
||||||
|
})
|
||||||
|
test("map URL refuses duplicated, oversized and unknown context",()=>{
|
||||||
|
for(const query of ["server=1&server=2","q=x&q=y","filter=bad","dest=bad","wan=0","server=1&wan=-1","server=1&wan=99999","server=%20","q="+("x".repeat(257))])assert.ok(readNetworkMapUrl(new URLSearchParams(query)).error,query)
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const networkMapFilters=["all","online","degraded","offline","unknown","jump-host","exit-node","home-router"] as const
|
||||||
|
export type NetworkMapFilter=typeof networkMapFilters[number]
|
||||||
|
export function readNetworkMapUrl(params:URLSearchParams){
|
||||||
|
const server=params.get("server"),search=params.get("q")??"",filter=params.get("filter")??"all",dest=params.get("dest")??"services",wan=params.get("wan")
|
||||||
|
let error:string|null=null
|
||||||
|
if(["server","q","filter","dest","wan"].some(key=>params.getAll(key).length>1))error="В ссылке повторяется контекст карты."
|
||||||
|
else if(server!==null&&!/^[a-zA-Z0-9_.:-]{1,64}$/.test(server))error="В ссылке указан некорректный сервер карты."
|
||||||
|
else if(search.length>256)error="Поиск карты в ссылке слишком длинный."
|
||||||
|
else if(!(networkMapFilters as readonly string[]).includes(filter))error="В ссылке указан неизвестный фильтр карты."
|
||||||
|
else if(!["services","countries"].includes(dest))error="В ссылке указан неизвестный режим назначений."
|
||||||
|
else if(wan!==null&&(!server||!/^\d{1,4}$/.test(wan)))error="В ссылке указан некорректный аплинк карты."
|
||||||
|
return {server,search,filter:filter as NetworkMapFilter,dest:dest as "services"|"countries",wan:wan===null?null:Number(wan),error}
|
||||||
|
}
|
||||||
|
export function patchNetworkMapUrl(params:URLSearchParams,values:Record<string,string|null>){
|
||||||
|
const next=new URLSearchParams(params)
|
||||||
|
for(const[key,value]of Object.entries(values)){if(value===null||value==="")next.delete(key);else next.set(key,value)}
|
||||||
|
return "/network-map"+(next.size?"?"+next.toString():"")
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {test,expect} from "@playwright/test"
|
||||||
|
const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false}
|
||||||
|
const server={id:1,name:"Router URL",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,wanUplinks:[{id:"w",name:"WAN URL",iface:"ether1",isp:"ISP",ip:"192.0.2.1",maxDl:100,maxUl:100}]}
|
||||||
|
test.beforeEach(async({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"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
|
||||||
|
})
|
||||||
|
test("Карта URL: устройство/WAN, фильтр, поиск, назначения, reload и история",async({page})=>{
|
||||||
|
await page.goto("/network-map?server=1&wan=0&filter=unknown&q=Router&dest=countries&tag=keep#anchor")
|
||||||
|
await expect(page.getByText("Ресурсы",{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByRole("textbox",{name:"Поиск узла",exact:true})).toHaveValue("Router")
|
||||||
|
await page.reload();await expect(page.getByText("Ресурсы",{exact:true})).toBeVisible()
|
||||||
|
await page.getByRole("button",{name:"Онлайн",exact:true}).click()
|
||||||
|
await expect(page).toHaveURL(/filter=online/);await page.goBack();await expect(page).toHaveURL(/filter=unknown/)
|
||||||
|
await page.getByRole("button",{name:"Сервисы",exact:true}).click();await expect(page).not.toHaveURL(/dest=countries/);await page.goBack();await expect(page).toHaveURL(/dest=countries/)
|
||||||
|
await page.getByRole("textbox",{name:"Поиск узла",exact:true}).fill("Router URL");await expect.poll(()=>new URL(page.url()).searchParams.get("q")).toBe("Router URL")
|
||||||
|
await expect(page).toHaveURL(/tag=keep/);await expect(page).toHaveURL(/#anchor$/)
|
||||||
|
await page.getByRole("button",{name:"Устройство Router URL",exact:true}).focus();await page.keyboard.press("Escape");await expect(page).not.toHaveURL(/server=/);await expect(page).not.toHaveURL(/wan=/)
|
||||||
|
await page.getByRole("button",{name:"Устройство Router URL",exact:true}).click();await expect(page).toHaveURL(/server=1/)
|
||||||
|
})
|
||||||
|
for(const query of ["filter=bad","dest=bad","q=x&q=y","wan=0"])test("Карта URL: неверный контекст "+query,async({page})=>{
|
||||||
|
let reads=0;await page.route("**/api/servers",r=>{reads++;return r.fallback()})
|
||||||
|
await page.goto("/network-map?tag=keep&"+query+"#anchor")
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Контекст карты недоступен"})).toBeVisible();expect(reads).toBe(0)
|
||||||
|
await page.getByRole("button",{name:"Сбросить контекст карты",exact:true}).click()
|
||||||
|
await expect(page.getByRole("button",{name:"Устройство Router URL",exact:true})).toBeVisible();await expect(page).toHaveURL(/tag=keep#anchor$/)
|
||||||
|
})
|
||||||
|
for(const query of ["server=99","server=1&wan=99"])test("Карта URL: недоступный объект "+query,async({page})=>{
|
||||||
|
await page.goto("/network-map?"+query)
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Объект карты недоступен"})).toBeVisible()
|
||||||
|
if(query==="server=99")await expect(page.getByText("Ресурсы",{exact:true})).toHaveCount(0)
|
||||||
|
await page.getByRole("button",{name:"Сбросить выбранный объект",exact:true}).click();await expect(page).not.toHaveURL(/server=/)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user