@@ -2075,7 +2088,7 @@ function NetworkMapContent() {
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
placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring w-44"
/>
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 2cb752e7..73c3782b 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.50; оптимизатор принят локально, карта сети в работе |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.51; оптимизатор принят локально, карта сети в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 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)
- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index ecd96510..0063efba 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -276,3 +276,11 @@ Shared контракт и backend observed исходные значения/д
- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
- Далее 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, скриншоты и локальная приёмка карты.
+
diff --git a/lib/network-map-url-state.test.ts b/lib/network-map-url-state.test.ts
new file mode 100644
index 00000000..4123dcda
--- /dev/null
+++ b/lib/network-map-url-state.test.ts
@@ -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)
+})
diff --git a/lib/network-map-url-state.ts b/lib/network-map-url-state.ts
new file mode 100644
index 00000000..a9a9637e
--- /dev/null
+++ b/lib/network-map-url-state.ts
@@ -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){
+ 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():"")
+}
diff --git a/tests/e2e/network-map-url.spec.ts b/tests/e2e/network-map-url.spec.ts
new file mode 100644
index 00000000..f46bc1eb
--- /dev/null
+++ b/tests/e2e/network-map-url.spec.ts
@@ -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=/)
+})