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 01805bdd..7696cf19 100644
--- a/components/data-grids/route-optimizer-comm-recs-data-grid.tsx
+++ b/components/data-grids/route-optimizer-comm-recs-data-grid.tsx
@@ -32,7 +32,8 @@ function Chip({ children, color }: { children: React.ReactNode; color?: string }
)
}
-function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
+function ProbChip({ prob, best }: { prob: number | null; best?: boolean }) {
+ if (prob === null) return —
return (
{r.recommended.jh}
{r.recommended.exit}
- {r.shouldSwitch && !isPinned && (
+ {r.shouldSwitch && r.current && !isPinned && (
- +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)} б.
+ +{r.recommended.prob - r.current.prob} б.
)}
@@ -164,9 +165,9 @@ function RouteOptimizerCommRecsDataGrid({
const pinKey = `${homeId}::${r.community}`
return (
)
},
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 40e073ee..fd4f1666 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -26,11 +26,17 @@
| 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.34; адаптивность оптимизатора проверена, далее достоверность индексов/контекст |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.35; отсутствующий индекс не подменяется нулём, далее URL/source-контекст |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.35 — неизвестные индексы Community (2026-09-30)
+
+- Если полной цепочки нет, оба индекса показывают «—» с доступным названием; отсутствующая база не участвует в вычислении отображаемого выигрыша. Переключение не рекомендуется даже при нулевом пороге. Формула ранжирования полных цепочек не менялась.
+- Проверки: 7 E2E (включая отсутствие ExitNode, полные таблицы и симуляцию), 3 unit (включая нулевой порог), TypeScript и профильный lint прошли. Production frontend был проверен в предыдущем блоке; общая основа не менялась.
+- Далее: URL/source-контекст оптимизатора. Model fallback/clamp отдельно обозначить, затем карта/терминал; реальная RouterOS/БД приёмка внешняя.
+
## Блок 10.34 — адаптивность и доступность оптимизатора (2026-09-30)
- KPI читаются на 320/390 px: одна колонка на телефоне, две/четыре при достаточной ширине контейнера. Визуальная проверка выявила скрытые KPI внутри native flex-fieldset; внутренний flex-контейнер восстановил layout без изменения общего KpiStatGrid.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index f9b05052..7fa6e6f1 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -188,3 +188,7 @@ Observed selector вместо default cost/disabled; неоднозначные
## 10.34. Адаптивность оптимизатора
1440/390/320 px, keyboard/ARIA и disabled ECMP; native fieldset композиция исправлена после визуального обнаружения невидимых KPI. Семантические токены четырёх таблиц, девять снимков в плане; 35 E2E, TS/lint и production frontend прошли. Teardown catalog отказ backend записан в журнале. URL/source и model provenance ещё открыты.
+
+## 10.35. Отсутствующие индексы Community
+
+Без полной цепочки индексы неизвестны, рекомендации нет даже при пороге 0. 7 E2E, 3 unit, TS/lint прошли; расчёт существующих цепочек прежний. Далее URL/source-контекст.
diff --git a/lib/route-optimizer-data.ts b/lib/route-optimizer-data.ts
index e2266ca1..5bb0d3e8 100644
--- a/lib/route-optimizer-data.ts
+++ b/lib/route-optimizer-data.ts
@@ -559,13 +559,13 @@ export function buildLiveOptimizerData(
prob: curProb,
}
: null
- const diff = (rec?.prob ?? 0) - (cur?.prob ?? 0)
+ const diff = rec && cur ? rec.prob - cur.prob : null
return {
community: rule.community,
communityName: rule.communityName ?? rule.community,
current: cur,
recommended: rec,
- shouldSwitch: diff >= settings.switchThreshold,
+ shouldSwitch: diff !== null && diff >= settings.switchThreshold,
pinnedBySettings: false,
}
})
diff --git a/lib/route-optimizer-response.test.ts b/lib/route-optimizer-response.test.ts
index c779e323..5fb43137 100644
--- a/lib/route-optimizer-response.test.ts
+++ b/lib/route-optimizer-response.test.ts
@@ -16,3 +16,12 @@ test("optimizer refuses unknown types, duplicate inventory/WAN and invalid metri
for(const value of [[{...server,type:undefined}],[server,server],[{...server,latency:-1}],[{...server,wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"",maxDl:0,maxUl:10}]}]]) assert.ok(!optimizerServersSchema.safeParse(value).success)
for(const value of [[probe,probe],[{...probe,lastPingRttMs:-1}],[{...probe,lastPingLossPct:101}],[{...probe,lastPingAt:"yesterday"}],[{...probe,lastTxAvgMbps:undefined}]]) assert.ok(!optimizerProbesSchema.safeParse({probes:value}).success)
})
+
+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 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]
+ assert.equal(recommendation.current,null);assert.equal(recommendation.recommended,null);assert.equal(recommendation.shouldSwitch,false)
+})
diff --git a/tests/e2e/route-optimizer-tables.spec.ts b/tests/e2e/route-optimizer-tables.spec.ts
index 9aefe660..61c571b1 100644
--- a/tests/e2e/route-optimizer-tables.spec.ts
+++ b/tests/e2e/route-optimizer-tables.spec.ts
@@ -8,7 +8,7 @@ const servers: OptimizerApiServer[] = (["home-router", "jump-host", "exit-node"]
...(i === 0 ? { wanUplinks: ids.map(id => ({ id: `wan${id}`, name: `WAN${id}`, isp: "ISP", iface: `ether${id}`, ip: `198.51.100.${id}`, maxDl: 100, maxUl: 100 })) } : {}),
}))
-async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok") {
+async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok", missingExit = false) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
@@ -17,7 +17,7 @@ async function setup(page: Page, preview: "ok" | "empty" | "error" = "ok") {
const interfaces = preview === "empty" ? [] : ids.map(i => ({ id: String(i), interface: `ether${i}`, currentCost: i, optimalCost: 20, score: i, pingMs: i, dlMbps: 100, ulMbps: 50 }))
return route.fulfill({ json: {serverId:1,serverName:"Роутер 1",pingWeight:60,unchangedCount:0, interfaces, changes: interfaces, changedCount: interfaces.length, interfacesTotal: interfaces.length } })
}
- return route.fulfill({ json: path === "/api/servers" ? servers
+ return route.fulfill({ json: path === "/api/servers" ? servers.filter(server => !missingExit || server.type !== "exit-node")
: path === "/api/auth/config" ? { required: false }
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: ids.map(i => ({ id: String(i), community: `65001:${i}`, communityName: `Сервис ${i}`, gateway: "192.0.2.2", gatewayTunnelId: "", description: "" })) }] }
@@ -74,3 +74,13 @@ for (const state of ["empty", "error"] as const) {
await expect(page.getByRole("table").filter({ hasText: "Speed (dl/ul)" })).toHaveCount(0)
})
}
+
+test("Оптимизатор: без ExitNode отсутствующие индексы не становятся нулями", async ({page}) => {
+ await setup(page,"ok",true)
+ await page.getByRole("button",{name:"BGP community (12)",exact:true}).click()
+ const grid=page.getByRole("table").filter({hasText:"Community"})
+ await expect(grid.locator("tr[data-row-id]")).toHaveCount(12)
+ await expect(grid.getByLabel("Индекс неизвестен")).toHaveCount(24)
+ await expect(grid).not.toContainText("0 б.")
+ await expect(grid.getByRole("button",{name:"Симулировать",exact:true})).toHaveCount(0)
+})