fix(ui): не подменять неизвестный индекс маршрута нулём

This commit is contained in:
Denozordec committed 2026-09-30 23:27:14 +07:00
1 parent fb70d80220
commit 2cdfa2cb13
6 files changed
+40 -10

No files matched your search

@@ -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 <span className="text-muted-foreground" aria-label="Индекс неизвестен">—</span>
return (
<Chip
color={
@@ -141,9 +142,9 @@ function RouteOptimizerCommRecsDataGrid({
<span>{r.recommended.jh}</span>
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
{r.shouldSwitch && r.current && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-warning/10 border border-warning/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)} б.
+{r.recommended.prob - r.current.prob} б.
</span>
)}
</div>
@@ -164,9 +165,9 @@ function RouteOptimizerCommRecsDataGrid({
const pinKey = `${homeId}::${r.community}`
return (
<div className="flex items-center justify-center gap-1">
<ProbChip prob={r.current?.prob ?? 0} />
<ProbChip prob={r.current?.prob ?? null} />
<span className="text-muted-foreground text-[10px]">/</span>
<ProbChip prob={r.recommended?.prob ?? 0} best={r.shouldSwitch && !pinned.has(pinKey)} />
<ProbChip prob={r.recommended?.prob ?? null} best={r.shouldSwitch && !pinned.has(pinKey)} />
</div>
)
},
+7 -1
View File
@@ -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.
+4
View File
@@ -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-контекст.
+2 -2
View File
@@ -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,
}
})
+9
View File
@@ -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)
})
+12 -2
View File
@@ -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)
})