@@ -3339,7 +3345,7 @@ function NetworkMapContent() {
{myEdges.length > 0 && (
- Подключения к JH
+ Модель связей с JH · Ping модели
{myEdges.map((e) => {
const jh = mapServers.find(s => s.id === e.jhId)
@@ -3405,7 +3411,7 @@ function NetworkMapContent() {
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}>
- {isPrimary ? "primary" : "backup"}
+ {isPrimary ? "Основная на схеме" : "Дополнительная на схеме"}
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
@@ -3418,7 +3424,7 @@ function NetworkMapContent() {
{t.ipsec
?
IPsec
- :
Plain}
+ :
{useLiveData?"IPsec неизвестен":"Plain (демо)"}}
{showMetrics && (
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 8e645da5..2cb752e7 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.49; оптимизатор принят локально, карта сети в работе |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.50; оптимизатор принят локально, карта сети в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01)
+
+- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
+- Пунктир WAN→JH прямо обозначен моделью связности. Live модель не анимируется как активный маршрут и не показывает сумму latency как RTT линии; Ping модели подписан отдельно в деталях. Probe значения сохраняют нули/частичные DL/UL; подпись показывает probe ID, дату Ping и неизвестное время скорости.
+- Узлы всех ролей получили имя устройства и keyboard Enter/Space. Пульсация размера нарушала стабильность области кнопки; удалена, имя видно и для JH/Exit. Прежние React set-state/refs/immutability/exhaustive-deps и prefer-const устранены без подавления lint.
+- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
+- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты.
+
## Блок 10.49 — поздние ответы при смене источника карты (2026-10-01)
- Добавлена отдельная source-конфигурация Playwright (:3101, исходный backend :8000 и новый :8101). 12 сценариев snapshot/Flow/DNS × backend/mock × успех/отказ подтверждают, что прежняя карта, сервис и ошибка не попадают в новый источник, а старый ответ не запускает дополнительные чтения.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index 0aa263a0..ecd96510 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -267,3 +267,12 @@ Shared контракт и backend observed исходные значения/д
- Все 12 E2E и lint новых тестов/config прошли. Default suite исключает source-файл; Next серверы запускаются последовательно.
- Далее выбранные детали из текущего снимка, подписи модельных WAN связей и прежние lint ошибки карты; затем URL, клавиатура, телефон и визуальная приёмка.
+
+## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01)
+
+- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
+- Пунктир WAN→JH прямо обозначен моделью связности. Live модель не анимируется как активный маршрут и не показывает сумму latency как RTT линии; Ping модели подписан отдельно в деталях. Probe значения сохраняют нули/частичные DL/UL; подпись показывает probe ID, дату Ping и неизвестное время скорости.
+- Узлы всех ролей получили имя устройства и keyboard Enter/Space. Пульсация размера нарушала стабильность области кнопки; удалена, имя видно и для JH/Exit. Прежние React set-state/refs/immutability/exhaustive-deps и prefer-const устранены без подавления lint.
+- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
+- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты.
+
diff --git a/lib/map-gre-speed-probe.ts b/lib/map-gre-speed-probe.ts
index 4f0fd76f..5ed6065d 100644
--- a/lib/map-gre-speed-probe.ts
+++ b/lib/map-gre-speed-probe.ts
@@ -16,6 +16,7 @@ export interface GreSpeedProbeSnapshot {
dstInterface?: string
lastTxAvgMbps?: number | null
lastRxAvgMbps?: number | null
+ lastPingAt?: string | null
lastPingRttMs?: number | null
}
diff --git a/lib/network-map-layout.ts b/lib/network-map-layout.ts
index 40c1f2ac..f7684aac 100644
--- a/lib/network-map-layout.ts
+++ b/lib/network-map-layout.ts
@@ -826,8 +826,8 @@ export function buildGreMapEdges(
}
for (const t of tunnels) {
- let a = servers.find((s) => s.id === t.serverId)
- let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
+ const a = servers.find((s) => s.id === t.serverId)
+ const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!a || !b) continue
if (a.id === b.id) continue
diff --git a/tests/e2e/network-map-detail.spec.ts b/tests/e2e/network-map-detail.spec.ts
new file mode 100644
index 00000000..c3aefff1
--- /dev/null
+++ b/tests/e2e/network-map-detail.spec.ts
@@ -0,0 +1,46 @@
+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 wan={id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"192.0.2.1",maxDl:100,maxUl:100}
+const home={id:1,name:"Домашний",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:2,wanUplinks:[wan]}
+const jh={id:2,name:"Jump",host:"192.0.2.2",type:"jump-host",country:"RU",enabled:true,status:"online",latency:3}
+const tunnel={id:"t1",serverId:"1",name:"gre-home",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1",remoteInnerIp:"10.0.0.2",poolId:"",ipsec:null,mtu:1476,keepaliveInterval:10,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
+test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}))})
+test("Карта: открытые детали обновляются вместе со снимком",async({page})=>{
+ let changed=false
+ await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[{...jh,name:changed?"Новый Jump":"Jump",status:changed?null:"online",cpuLoad:changed?0:20}]: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}:{}})})
+ await page.goto("/network-map");await page.getByRole("button",{name:"Устройство Jump",exact:true}).click()
+ await expect(page.getByText("20%",{exact:true})).toBeVisible()
+ const sessions=page.getByText("Сессий",{exact:true}).locator("..");await expect(sessions).toHaveText("Сессий—")
+ changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
+ await expect(page.getByText("0%",{exact:true})).toBeVisible();await expect(page.locator("span").filter({hasText:/^Неизвестно$/}).first()).toBeVisible()
+ await expect(page.getByText("Jump",{exact:true})).toHaveCount(0);await expect(page.getByText("Новый Jump",{exact:true}).last()).toBeVisible()
+})
+test("Карта: модель WAN не утверждает активный маршрут и RTT линии",async({page})=>{
+ await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]: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}:{}})})
+ await page.goto("/network-map");await expect(page.getByText("Пунктир WAN→JH — модель связности; маршрут не подтверждён.",{exact:true})).toBeVisible()
+ await expect(page.locator("svg text").filter({hasText:/^5 мс$/})).toHaveCount(0)
+ await page.getByText("Домашний",{exact:true}).first().click()
+ await expect(page.getByText("Аплинк каталога",{exact:true})).toBeVisible();await expect(page.getByText("Модель связей с JH · Ping модели",{exact:true})).toBeVisible()
+ await expect(page.getByText("primary",{exact:true})).toHaveCount(0);await expect(page.getByText("backup",{exact:true})).toHaveCount(0)
+})
+test("Карта: выбранный GRE обновляется и неизвестный IPsec не означает Plain",async({page})=>{
+ let changed=false
+ await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[{...tunnel,name:changed?"gre-new":"gre-home",status:changed?"down":"up"}],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
+ await page.goto("/network-map")
+ await page.locator("g").filter({has:page.locator("title",{hasText:"RTT неизвестен."})}).last().click()
+ await expect(page.getByText("gre-home",{exact:true}).last()).toBeVisible();await expect(page.getByText("Ping неизвестен",{exact:true})).toBeVisible()
+ changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
+ await expect(page.getByText("gre-new",{exact:true}).last()).toBeVisible();await expect(page.getByText("Недоступен",{exact:true})).toBeVisible()
+ await page.getByText("Домашний",{exact:true}).first().click();await expect(page.getByText("IPsec неизвестен",{exact:true})).toBeVisible()
+})
+
+test("Карта: GRE сохраняет частичное измерение, ноль Ping и дату пробы",async({page})=>{
+ const probe={id:"measured-probe",srcServerId:"1",dstServerId:"2",srcInterface:"gre-home",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0.125,lastRxAvgMbps:null,lastPingAt:"2020-01-01T00:00:00Z"}
+ await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[tunnel],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[probe]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
+ await page.goto("/network-map")
+ await page.locator("g").filter({has:page.locator("title",{hasText:"Проба measured-probe"})}).last().click()
+ await expect(page.getByText("0 мс",{exact:true}).last()).toBeVisible()
+ await expect(page.getByText("↓0.125 ↑—",{exact:true}).last()).toBeVisible()
+ await expect(page.getByText("Проба measured-probe",{exact:false}).last()).toContainText("2020")
+ await expect(page.getByText("Проба measured-probe",{exact:false}).last()).toContainText("время TX/RX неизвестно")
+})