())
+ useEffect(() => () => { for (const timer of simulationTimers.current) clearTimeout(timer) }, [])
function applyRec(community: string, homeId: string) {
+ if (useLiveData) return
const key = `${homeId}::${community}`
+ if (pendingSimulations.current.has(key) || applied.has(key)) return
+ pendingSimulations.current.add(key)
setApplying(prev => new Set(prev).add(key))
- setTimeout(() => {
+ const timer = setTimeout(() => {
+ pendingSimulations.current.delete(key)
+ simulationTimers.current.delete(timer)
setApplied(prev => new Set(prev).add(key))
setApplying(prev => { const n = new Set(prev); n.delete(key); return n })
}, 1200)
+ simulationTimers.current.add(timer)
}
const totalSwitches = useMemo(
@@ -606,13 +609,14 @@ export default function RouteOptimizerPage() {
{
label: "Переключений",
value: totalSwitches,
- sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
+ sub: totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
icon: ,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
function applyAll() {
+ if (useLiveData) return
data?.homes.forEach(h =>
h.commRecs
.filter(r => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`))
@@ -724,9 +728,9 @@ export default function RouteOptimizerPage() {
crumbs={[{ label: "Инструменты" }, { label: "Оптимизатор маршрутов" }]}
actions={
<>
- {totalSwitches > 0 && (
+ {!useLiveData && totalSwitches > 0 && (
)}
{showSettings && (
@@ -838,28 +845,14 @@ export default function RouteOptimizerPage() {
-
Автоприменение
-
- Применять автоматически
- set("autoApply", v)} />
-
- {settings.autoApply && (
- <>
-
- set("autoApplyIntervalMin", v)} min={5} max={1440} />
-
-
-
- Изменения каждые {settings.autoApplyIntervalMin} мин
-
- >
- )}
+
Автоприменение Community недоступно
+
Настройки расчёта сохраняются в этом браузере. Для сетевых изменений используйте профильные разделы.
@@ -1078,7 +1071,7 @@ export default function RouteOptimizerPage() {
- Оптимизатор работает в VRF {selectedVrf}
+ Выбран черновой контекст VRF {selectedVrf}
@@ -1090,12 +1083,12 @@ export default function RouteOptimizerPage() {
key={entry.home.id}
entry={entry}
jumpHosts={jumpHostsForCards}
- settings={settings}
pinned={pinned}
applied={applied}
applying={applying}
onPin={togglePin}
onApply={applyRec}
+ simulation={!useLiveData}
/>
))}
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 4d4eddec..6af894ac 100644
--- a/components/data-grids/route-optimizer-comm-recs-data-grid.tsx
+++ b/components/data-grids/route-optimizer-comm-recs-data-grid.tsx
@@ -41,7 +41,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
: "bg-muted text-muted-foreground border-border"
}
>
- {prob}%
+ {prob} б.
)
}
@@ -53,6 +53,7 @@ interface RouteOptimizerCommRecsDataGridProps {
applied: Set
applying: Set
onPin: (key: string) => void
+ simulation?: boolean
onApply: (community: string, homeId: string) => void
}
@@ -64,6 +65,7 @@ function RouteOptimizerCommRecsDataGrid({
applying,
onPin,
onApply,
+ simulation = false,
}: RouteOptimizerCommRecsDataGridProps) {
const rows = useMemo(
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
@@ -94,7 +96,7 @@ function RouteOptimizerCommRecsDataGrid({
id: "current",
header: () => (
- Текущий (WAN → JH → Exit)
+ Базовый расчёт (WAN → JH → Exit)
),
enableSorting: false,
@@ -141,7 +143,7 @@ function RouteOptimizerCommRecsDataGrid({
{r.recommended.exit}
{r.shouldSwitch && !isPinned && (
- +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
+ +{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)} б.
)}
@@ -153,7 +155,7 @@ function RouteOptimizerCommRecsDataGrid({
id: "probability",
header: () => (
- P(тек / рек)
+ Индекс (баз / рек)
),
enableSorting: false,
@@ -185,9 +187,9 @@ function RouteOptimizerCommRecsDataGrid({
const r = row.original
const pinKey = `${homeId}::${r.community}`
const isPinned = pinned.has(pinKey)
- const isApplied = applied.has(pinKey)
- const isApplying = applying.has(pinKey)
- const canApply = r.shouldSwitch && !isPinned && !isApplied
+ const isApplied = simulation && applied.has(pinKey)
+ const isApplying = simulation && applying.has(pinKey)
+ const canApply = simulation && r.shouldSwitch && !isPinned && !isApplied
return (
e.stopPropagation()}>
{isPinned &&
}
@@ -210,13 +212,13 @@ function RouteOptimizerCommRecsDataGrid({
{isApplying ? (
) : (
- "Применить"
+ "Симулировать"
)}
)}
{isApplied && (
- ✓ применено
+ ✓ симуляция завершена
)}
@@ -228,7 +230,7 @@ function RouteOptimizerCommRecsDataGrid({
},
},
],
- [applied, applying, homeId, onApply, onPin, pinned],
+ [applied, applying, homeId, onApply, onPin, pinned, simulation],
)
const table = useTable({
diff --git a/components/data-grids/route-optimizer-full-routes-data-grid.tsx b/components/data-grids/route-optimizer-full-routes-data-grid.tsx
index 668edb16..ce20553b 100644
--- a/components/data-grids/route-optimizer-full-routes-data-grid.tsx
+++ b/components/data-grids/route-optimizer-full-routes-data-grid.tsx
@@ -41,7 +41,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
: "bg-muted text-muted-foreground border-border"
}
>
- {prob}%
+ {prob} б.
)
}
@@ -211,7 +211,7 @@ function RouteOptimizerFullRoutesDataGrid({
id: "prob",
accessorKey: "probabilityOptimal",
header: ({ column }) => (
-
+
),
cell: ({ row }) => {
const isBest = row.original.id === bestId || row.original._index === 0
@@ -222,7 +222,7 @@ function RouteOptimizerFullRoutesDataGrid({
)
},
meta: {
- headerTitle: "P(opt)",
+ headerTitle: "Индекс",
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
},
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 912b6c42..8f725b95 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.28; рекурсивные маршруты приняты локально, далее оптимизатор |
+| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.29; Community оптимизатора показывает расчёт, далее strict read/OSPF |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
+## Блок 10.29 — достоверность рекомендаций оптимизатора (2026-09-30)
+
+- Live Community больше не показывает успех по таймеру: действия симуляции доступны только в демо. Переключение backend/mode размонтирует прежний контекст; таймеры симуляции отменяются, повтор синхронно заблокирован.
+- Базовая цепочка подписана как расчётная; проценты вероятности заменены баллами индекса. Указано, что метрики могут включать модельные оценки, а ECMP/RPF/VRF служат локальному просмотру. Автоприменение Community отмечено недоступным; пересчёт настроек подписан явно. Алгоритм live ранжирования и сетевые операции не изменены.
+- Демо-матрицы теперь сопоставлены с текущими mock server IDs: прежние jh/ex ID оставляли их пустыми. Проверена успешная симуляция с отдельной подписью. Настройки раскрываются настоящей кнопкой с aria-expanded.
+- Проверки: шесть E2E (таблицы/preview, live без фиктивной записи, mock симуляция), TS/lint прошли. Первый прогон выявил div вместо кнопки и несовместимость демо-ID; исправлены.
+- Далее 10.30: строгие ответы inventory/rules/probes, ошибки частей и последние данные, epoch/freshness. Затем OSPF preview/apply validation/права/контекст и visual acceptance. Раздел целиком ещё не принят; фактическая текущая Community цепочка и вероятности API отсутствуют. Последняя квота: 66% окна, 41% недели использовано. Локальный коммит без push.
+
## Блок 10.28 — адаптивность и локальная приёмка маршрутов (2026-09-30)
- Раскрытие группы — отдельная кнопка с aria-expanded, Enter/Space; URL и клик строки сохранены. Форма имеет доступные имена полей и штатный finalFocus Base UI после Escape.
diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md
index a8961a06..5f6b77c1 100644
--- a/docs/ux-ui-stage-10.md
+++ b/docs/ux-ui-stage-10.md
@@ -164,3 +164,7 @@ server/q/route/table принадлежат экрану; default replace, вы
## 10.28. Локальная приёмка рекурсивных маршрутов
48 основных + 8 source E2E, backend recursive tests, lint/production frontend прошли. 1440/390/320 px, keyboard раскрытие, trap и возврат фокуса, parent-owned scroll; шесть экранов в плане. Неизвестный тип не подменяется EN. ReUI audit consumer-композиции выполнен без изменения vendor/theme. Далее оптимизатор; RouterOS/DB/JWT остаются внешней приёмкой.
+
+## 10.29. Community: расчёт и симуляция
+
+Live таймерный успех убран; demo симуляция явно подписана, cleanup/lock сохранены. Модельная базовая цепочка и индекс в баллах обозначены; автоприменение недоступно, ECMP/RPF/VRF локальные. Исправлены mock-ID матриц. 6 E2E, TS/lint прошли. Далее strict чтение и OSPF операции; раздел ещё не принят.
diff --git a/tests/e2e/route-optimizer-tables.spec.ts b/tests/e2e/route-optimizer-tables.spec.ts
index 5a384c8b..a80f7591 100644
--- a/tests/e2e/route-optimizer-tables.spec.ts
+++ b/tests/e2e/route-optimizer-tables.spec.ts
@@ -41,8 +41,8 @@ test("Оптимизатор: 12 WAN, все комбинации и закре
await expect(routes.locator("tr[data-row-id]")).toHaveCount(5)
await page.getByRole("button", { name: "Показать все 12 комбинаций", exact: true }).click()
await expect(routes.locator("tr[data-row-id]")).toHaveCount(12)
- await routes.getByRole("button", { name: "P(opt)", exact: true }).click()
- await routes.getByRole("button", { name: "P(opt)", exact: true }).click()
+ await routes.getByRole("button", { name: "Индекс", exact: true }).click()
+ await routes.getByRole("button", { name: "Индекс", exact: true }).click()
await expect(routes.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "1-wan12-2-3")
await page.getByRole("button", { name: "Свернуть", exact: true }).click()
await expect(routes.locator("tr[data-row-id]")).toHaveCount(5)
diff --git a/tests/e2e/route-optimizer-trust.spec.ts b/tests/e2e/route-optimizer-trust.spec.ts
new file mode 100644
index 00000000..6905831a
--- /dev/null
+++ b/tests/e2e/route-optimizer-trust.spec.ts
@@ -0,0 +1,35 @@
+import {test,expect} from "@playwright/test"
+const hosts=(["home-router","jump-host","exit-node"] as const).map((type,index)=>({id:index+1,name:"Роутер "+(index+1),host:"192.0.2."+(index+1),type,site:"TEST",country:"RU",enabled:true,status:"online",latency:10,model:"CHR",...(index===0?{wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}:{})}))
+test("Оптимизатор: live рекомендации не подтверждают фиктивную запись",async({page})=>{
+ let writes=0
+ await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
+ await page.route("**/api/**",r=>{
+ const p=new URL(r.request().url()).pathname
+ if(r.request().method()!=="GET"&&!p.endsWith("/preview"))writes++
+ return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?hosts:p==="/api/filters/rules"?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p==="/api/uptime/speed-probes"?{probes:[]}:p.endsWith("/preview")?{interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p.endsWith("/ospf")?{interfaces:[],instances:[],neighbors:[],bfdSessions:[]}:{}})
+ })
+ await page.goto("/route-optimizer")
+ await page.getByRole("button",{name:"BGP community (1)",exact:true}).click()
+ const grid=page.getByRole("table").filter({hasText:"Community"})
+ await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
+ await expect(grid.getByRole("columnheader",{name:"Базовый расчёт (WAN → JH → Exit)",exact:true})).toBeVisible()
+ await expect(grid.getByRole("button",{name:"Применить",exact:true})).toHaveCount(0)
+ await expect(grid.getByRole("button",{name:"Симулировать",exact:true})).toHaveCount(0)
+ await expect(page.getByRole("button",{name:/Симулировать все/})).toHaveCount(0)
+ await expect(page.getByText("✓ применено",{exact:true})).toHaveCount(0)
+ await expect(page.getByText(/Применение Community к устройству недоступно/)).toBeVisible()
+ await page.getByRole("button",{name:/Настройки оптимизатора/}).click()
+ await expect(page.getByText("Автоприменение Community недоступно",{exact:true})).toBeVisible()
+ await expect(page.getByText("Применять автоматически",{exact:true})).toHaveCount(0)
+ expect(writes).toBe(0)
+})
+test("Оптимизатор: демо подтверждает только симуляцию",async({page})=>{
+ await page.addInitScript(()=>localStorage.setItem("routerlists:data-source","mock"))
+ await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{}}))
+ await page.goto("/route-optimizer")
+ await page.getByRole("button",{name:/^BGP community/}).last().click()
+ const simulate=page.getByRole("button",{name:"Симулировать",exact:true}).first()
+ await expect(simulate).toBeVisible();await simulate.click()
+ await expect(page.getByText("✓ симуляция завершена",{exact:true}).first()).toBeVisible()
+ await expect(page.getByText("✓ применено",{exact:true})).toHaveCount(0)
+})