diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx index 1208fc3c..3accbbdd 100644 --- a/app/(main)/dashboard/page.tsx +++ b/app/(main)/dashboard/page.tsx @@ -114,8 +114,8 @@ export default function DashboardPage() { { id: "bgp", label: "Префиксы BGP", - value: dash.bgp ? fmtIntRu(dash.bgp.prefixSum) : "—", - hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount}` : "нет данных", + value: dash.bgp?.prefixSum != null ? fmtIntRu(dash.bgp.prefixSum) : "—", + hint: dash.bgp ? `Сессий установлено: ${dash.bgp.establishedCount ?? "неизвестно"}${dash.bgp.prefixSum === null ? " · префиксы неизвестны" : ""}` : "нет данных", href: "/bgp", icon: , iconClassName: "text-primary", diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index de4dec84..bb2d20b7 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. Следующий блок12.2.1: strict BGP catalog, partial и nullable counters на dashboard; затем остальные источники, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. +Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. Следующий блок12.2.2: traffic DTO/агрегация без выдуманных точек; затем остальные источники, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index a38830d8..b058ca4a 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,14 @@ +## 12.2.1 — строгая сводка BGP дашборда (2026-10-03) + +Дашборд перешёл с legacy sessions на существующий /api/bgp/catalog и bgpCatalogSchema. Malformed DTO, дубликаты, conflict sessions/failures и переполнение суммы отклоняются до публикации. Failure устройства не становится отсутствием сессий: источник сообщает ошибку, последний успешный KPI/время сохранены. Nullable prefixesRx установленной сессии даёт неизвестные префиксы, неизвестный/custom state — неизвестные префиксы и число установленных сессий; успешный пустой каталог подтверждает0. Exact Established не путается с NotEstablished. ACL проверяется до/после запроса, прежняя revision защита polling сохранена. + +Проверки:16 browser dashboard-data/permissions прошли (включая5 invalid/partial кейсов с unknown→success→failure/cache→recovery,2 nullable/state кейса и поздние ответы);3 unit (агрегация/строгий DTO/permission parity), typecheck и ESLint прошли. Новые fixtures содержат полный реальный DTO. ReUI checklist применён: KpiStatGrid сохранён, изменены data/copy, theme/primitives без изменений. + +Далее12.2.2: проверить traffic DTO/длины серий без искусственных60 точек и нулей; затем servers/probes/overlay/Internet path. Реальная телеметрия RouterOS остаётся внешней проверкой. + ## 12.1 — оболочка: контраст, клавиатура и health (2026-10-03) Подтверждённые нарушения автоматического axe-аудита исправлены на уровнях потребителей: доступный фокус main для skip link; высота toggle правила не менее24px; контраст API badge, подписей групп/Telegram и статуса backend. В открытом системном мониторе значения используют text-foreground, цвет остаётся у значка/полосы; длинная подпись переносится. Установленные ReUI/Base UI primitives и глобальная тема не изменены. Health использует shared getBackendHealth, подтверждённый legacy ok:true согласован с выбором источника; неподтверждённый ответ не становится успехом. axe-core4.11.3 закреплён прямой dev dependency. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index aa675957..5262206b 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -28,3 +28,12 @@ | --- | --- | --- | | Светлый монитор | [1440](ux-ui/screens/shell-monitor-light-1440.png) | [320](ux-ui/screens/shell-monitor-light-320.png) | | Тёмный монитор | [1440](ux-ui/screens/shell-monitor-dark-1440.png) | [320](ux-ui/screens/shell-monitor-dark-320.png) | + +## 12.2.1 — строгая сводка BGP дашборда (2026-10-03) + +Дашборд перешёл с legacy sessions на существующий /api/bgp/catalog и bgpCatalogSchema. Malformed DTO, дубликаты, conflict sessions/failures и переполнение суммы отклоняются до публикации. Failure устройства не становится отсутствием сессий: источник сообщает ошибку, последний успешный KPI/время сохранены. Nullable prefixesRx установленной сессии даёт неизвестные префиксы, неизвестный/custom state — неизвестные префиксы и число установленных сессий; успешный пустой каталог подтверждает0. Exact Established не путается с NotEstablished. ACL проверяется до/после запроса, прежняя revision защита polling сохранена. + +Проверки:16 browser dashboard-data/permissions прошли (включая5 invalid/partial кейсов с unknown→success→failure/cache→recovery,2 nullable/state кейса и поздние ответы);3 unit (агрегация/строгий DTO/permission parity), typecheck и ESLint прошли. Новые fixtures содержат полный реальный DTO. ReUI checklist применён: KpiStatGrid сохранён, изменены data/copy, theme/primitives без изменений. + +Далее12.2.2: проверить traffic DTO/длины серий без искусственных60 точек и нулей; затем servers/probes/overlay/Internet path. Реальная телеметрия RouterOS остаётся внешней проверкой. + diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 73f286b7..5bfe3115 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -32,6 +32,7 @@ import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-op import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state" import {can,ensureAuthConfig} from "@/lib/auth" +import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp" import {dashboardRequestPermission} from "@/lib/dashboard-permissions" async function dashboardRequest(path:string,work:()=>Promise,method="GET"):Promise{ await ensureAuthConfig();const permission=dashboardRequestPermission(method,path) @@ -251,7 +252,7 @@ export function useDashboardLive() { const [liveProbes, setLiveProbes] = useState(null) const [liveServers, setLiveServers] = useState(null) const [overlayItems, setOverlayItems] = useState(null) - const [bgp, setBgp] = useState<{ prefixSum: number; establishedCount: number } | null>(null) + const [bgp, setBgp] = useState(null) const [trafficSeries, setTrafficSeries] = useState<{ rx: number[] tx: number[] @@ -293,7 +294,7 @@ export function useDashboardLive() { apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"), apiFetch("/api/servers"), apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), - apiFetch>("/api/bgp/sessions"), + apiFetch("/api/bgp/catalog").then(parseDashboardBgp), apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"), dashboardRequest("/api/wireguard",()=>listWireGuard(backendUrl)), @@ -320,18 +321,7 @@ export function useDashboardLive() { setLiveServers(serversMapped) } - if (br.status === "fulfilled") { - let prefixSum = 0 - let establishedCount = 0 - for (const s of br.value) { - const st = String(s.state ?? "") - if (/established/i.test(st)) { - establishedCount += 1 - prefixSum += Number(s.prefixesRx ?? 0) - } - } - setBgp({ prefixSum, establishedCount }) - } + if (br.status === "fulfilled") setBgp(br.value) const greItems: OverlayItem[] = greRes.status === "fulfilled" diff --git a/lib/dashboard-bgp.test.ts b/lib/dashboard-bgp.test.ts new file mode 100644 index 00000000..8a7f9f8f --- /dev/null +++ b/lib/dashboard-bgp.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {parseDashboardBgp} from "./dashboard-bgp" +import {bgpSession} from "../tests/e2e/fixtures/bgp" +test("BGP global totals distinguish observed zero, unknown counters and unknown session state",()=>{ + assert.deepEqual(parseDashboardBgp({sessions:[],failures:[]}),{prefixSum:0,establishedCount:0}) + assert.deepEqual(parseDashboardBgp({sessions:[bgpSession()],failures:[]}),{prefixSum:321,establishedCount:1}) + assert.deepEqual(parseDashboardBgp({sessions:[bgpSession(1,null)],failures:[]}),{prefixSum:null,establishedCount:1}) + assert.deepEqual(parseDashboardBgp({sessions:[{...bgpSession(1,null),state:"Idle"}],failures:[]}),{prefixSum:0,establishedCount:0}) + for(const state of ["Unknown","NotEstablished","custom-status"])assert.deepEqual(parseDashboardBgp({sessions:[{...bgpSession(),state}],failures:[]}),{prefixSum:null,establishedCount:null}) +}) +test("BGP rejects partial catalogs, malformed counters, duplicates and overflow before publication",()=>{ + for(const raw of [{sessions:[]},{sessions:[{}],failures:[]},{sessions:[bgpSession(),bgpSession()],failures:[]},{sessions:[bgpSession(1,-1)],failures:[]},{sessions:[bgpSession(1,Number.MAX_SAFE_INTEGER),bgpSession(2,1)],failures:[]}])assert.throws(()=>parseDashboardBgp(raw)) + assert.throws(()=>parseDashboardBgp({sessions:[bgpSession()],failures:[{serverId:2,serverName:"Недоступный узел",error:"timeout"}]}),/Недоступный узел: timeout/) +}) diff --git a/lib/dashboard-bgp.ts b/lib/dashboard-bgp.ts new file mode 100644 index 00000000..387abf5c --- /dev/null +++ b/lib/dashboard-bgp.ts @@ -0,0 +1,16 @@ +import {bgpCatalogSchema} from "@/lib/bgp/response" +export interface DashboardBgp {prefixSum:number|null;establishedCount:number|null} +/** A failed device cannot be counted as having no sessions in the global KPI. */ +export function parseDashboardBgp(raw:unknown):DashboardBgp { + const catalog=bgpCatalogSchema.safeParse(raw) + if(!catalog.success)throw Error("Некорректный ответ каталога BGP") + if(catalog.data.failures.length)throw Error("Не опрошены устройства BGP: "+catalog.data.failures.map(f=>f.serverName+": "+f.error).join("; ")) + let prefixSum=0,establishedCount=0,missingPrefix=false,unknownState=false + for(const session of catalog.data.sessions){ + const state=session.state.toLowerCase() + if(state==="established"){establishedCount++;if(session.prefixesRx===null)missingPrefix=true;else prefixSum+=session.prefixesRx} + else if(!["active","idle","connect","opensent","openconfirm"].includes(state))unknownState=true + } + if(!Number.isSafeInteger(prefixSum))throw Error("Суммарный счётчик BGP превышает допустимый диапазон") + return {prefixSum:unknownState||missingPrefix?null:prefixSum,establishedCount:unknownState?null:establishedCount} +} diff --git a/lib/dashboard-permissions.test.ts b/lib/dashboard-permissions.test.ts index 4ff3cc6f..ad4fbc48 100644 --- a/lib/dashboard-permissions.test.ts +++ b/lib/dashboard-permissions.test.ts @@ -3,6 +3,6 @@ import {test} from "node:test" import {dashboardRequestPermission} from "./dashboard-permissions" import {permissionForRequest} from "../backend/src/lib/permissions" test("dashboard reader gates match real backend permissions including fallback diagnostic POST",()=>{ - for(const [method,path] of [["GET","/api/uptime/overview?range=1h"],["GET","/api/servers"],["GET","/api/filters/rules"],["GET","/api/bgp/sessions"],["GET","/api/internet-path/latest"],["GET","/api/filters/gre-tunnels"],["GET","/api/wireguard"],["GET","/api/traffic/servers?range=1h"],["GET","/api/vxlan"],["GET","/api/events?limit=6"],["GET","/api/servers/1/wan-runtime"],["POST","/api/servers/1/probes/run"],["GET","/api/uptime/speed-probes"]])assert.equal(dashboardRequestPermission(method,path),permissionForRequest(method,path)) + for(const [method,path] of [["GET","/api/uptime/overview?range=1h"],["GET","/api/servers"],["GET","/api/filters/rules"],["GET","/api/bgp/catalog"],["GET","/api/internet-path/latest"],["GET","/api/filters/gre-tunnels"],["GET","/api/wireguard"],["GET","/api/traffic/servers?range=1h"],["GET","/api/vxlan"],["GET","/api/events?limit=6"],["GET","/api/servers/1/wan-runtime"],["POST","/api/servers/1/probes/run"],["GET","/api/uptime/speed-probes"]])assert.equal(dashboardRequestPermission(method,path),permissionForRequest(method,path)) assert.throws(()=>dashboardRequestPermission("GET","/api/unmapped"));assert.throws(()=>dashboardRequestPermission("POST","/api/events")) }) diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index b1cff613..7c187b57 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -1,8 +1,10 @@ import { test, expect, type Page } from "@playwright/test" import { serverReadSchema } from "../../packages/contracts/src/servers" -async function setup(page: Page, initiallyFailed = false, serverCount = 1) { - const control = { failed: initiallyFailed, partial: false, prefixes: 321, hold: false, holdPath: "/api/bgp/sessions", fallback: false, failFallback: false, pending: 0, release: () => {} } +import {bgpSession} from "./fixtures/bgp" + +async function setup(page: Page, initiallyFailed = false, serverCount = 1, initialBgpPayload?:unknown) { + const control = { failed: initiallyFailed, partial: false, prefixes: 321 as number|null, bgpPayload: initialBgpPayload as unknown, hold: false, holdPath: "/api/bgp/catalog", fallback: false, failFallback: false, pending: 0, release: () => {} } let release!: () => void const gate = new Promise(resolve => { release = resolve }) control.release = release @@ -16,7 +18,7 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1) { const path = new URL(route.request().url()).pathname if (path === "/api/auth/config") return route.fulfill({ json: { required: false } }) if (path === "/api/evobgp/settings") return route.fulfill({ json: { enabled: false, baseUrl: "", secretConfigured: false } }) - const fail = control.failed || (control.failFallback && path === "/api/uptime/speed-probes") || (control.partial && ["/api/bgp/sessions", "/api/filters/gre-tunnels", "/api/events", "/api/internet-path/latest", "/api/traffic/servers"].includes(path)) + const fail = control.failed || (control.failFallback && path === "/api/uptime/speed-probes") || (control.partial && ["/api/bgp/catalog", "/api/filters/gre-tunnels", "/api/events", "/api/internet-path/latest", "/api/traffic/servers"].includes(path)) const prefixes = control.prefixes if (control.hold && path === control.holdPath) { control.hold = false @@ -26,7 +28,7 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1) { if (fail) return route.fulfill({ status: 503, json: { error: "Источник временно недоступен" } }) const json = path === "/api/servers" ? servers : path === "/api/uptime/overview" ? { probes: [] } - : path === "/api/bgp/sessions" ? [{ state: "Established", prefixesRx: prefixes }] + : path === "/api/bgp/catalog" ? control.bgpPayload !== undefined ? control.bgpPayload : {sessions:[bgpSession(1,prefixes)],failures:[]} : path === "/api/filters/rules" ? { rulesets: [] } : path === "/api/filters/gre-tunnels" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] } : path === "/api/wireguard" ? { interfaces: [], peers: [] } @@ -126,7 +128,7 @@ test("Дашборд: поздний ответ не заменяет свежи control.prefixes = 999 await page.evaluate(() => window.dispatchEvent(new Event("mm:uptime-probes-changed"))) await expect(bgp(page)).toContainText("999") - const oldResponse = page.waitForResponse(response => response.url().includes("/api/bgp/sessions")) + const oldResponse = page.waitForResponse(response => response.url().includes("/api/bgp/catalog")) control.release() await (await oldResponse).finished() await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) @@ -203,3 +205,14 @@ test('Сквозная приёмка: проблема → опрос серв await expect(poll).toBeEnabled() expect(writes).toEqual(['/api/servers/2/poll', '/api/servers/2/poll']) }) + +for(const broken of ["missing","partial","duplicate","counter","conflict"])test("Dashboard strict BGP unknown and last good/"+broken,async({page})=>{ + const sessions=[bgpSession()],failure={serverId:2,serverName:"Узел без ответа",error:"timeout"};const payload=broken==="missing"?{sessions:[]}:broken==="partial"?{sessions,failures:[failure]}:broken==="duplicate"?{sessions:[...sessions,...sessions],failures:[]}:broken==="counter"?{sessions:[bgpSession(1,-1)],failures:[]}:{sessions,failures:[{...failure,serverId:1}]}; + const control=await setup(page,false,1,payload);const source=page.locator('[data-source="bgp"]');await expect(refresh(page)).toBeEnabled();await expect(source).toContainText("ошибка");await expect(bgp(page)).toContainText("—"); + control.bgpPayload=undefined;await refresh(page).click();await expect(bgp(page)).toContainText("321");const receivedAt=await source.locator("time").getAttribute("datetime");control.bgpPayload=payload;await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(bgp(page)).toContainText("321");await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка"); + control.bgpPayload=undefined;control.prefixes=999;await refresh(page).click();await expect(bgp(page)).toContainText("999");await expect(source).not.toContainText("ошибка"); +}) +for(const unknown of ["prefix","state"])test("Dashboard BGP accepts observed unknown without inventing zero/"+unknown,async({page})=>{ + const control=await setup(page);await expect(bgp(page)).toContainText("321");await expect(refresh(page)).toBeEnabled();control.bgpPayload={sessions:[unknown==="prefix"?bgpSession(1,null):{...bgpSession(),state:"Unknown"}],failures:[]};await refresh(page).click();await expect(bgp(page)).toContainText("—");await expect(bgp(page)).toContainText("префиксы неизвестны");await expect(bgp(page)).toContainText(unknown==="prefix"?"Сессий установлено: 1":"Сессий установлено: неизвестно");await expect(page.locator('[data-source="bgp"]')).not.toContainText("ошибка"); + control.bgpPayload={sessions:[],failures:[]};await refresh(page).click();await expect(bgp(page)).toContainText("Сессий установлено: 0");await expect(bgp(page)).not.toContainText("—"); +}) diff --git a/tests/e2e/dashboard-permissions.spec.ts b/tests/e2e/dashboard-permissions.spec.ts index 11f8a2cb..4ec46d24 100644 --- a/tests/e2e/dashboard-permissions.spec.ts +++ b/tests/e2e/dashboard-permissions.spec.ts @@ -1,6 +1,7 @@ import {test,expect} from "@playwright/test" +import {bgpSession} from "./fixtures/bgp" for(const bgp of [false,true])test("Dashboard limited JWT requests only authorized data sources/"+bgp,async({page})=>{ const permissions=["mm:dashboard:read",...(bgp?["mm:bgp:read"]:[])],token="header."+Buffer.from(JSON.stringify({sub:"fixture",apps:["mm"],permissions,exp:Math.floor(Date.now()/1000)+3600})).toString("base64url")+".fixture" await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);const calls:string[]=[] - await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:true}});calls.push(path);return r.fulfill({json:path==="/api/vxlan"?{tunnels:[]}:path==="/api/events"?{events:[]}:path==="/api/bgp/sessions"?[{state:"established",prefixesRx:321}]:{}})});await page.goto("/dashboard");const refresh=page.getByRole("button",{name:"Обновить данные",exact:true});await expect(refresh).toBeEnabled();await expect(page.locator('[data-source="servers"]')).toContainText("Нет доступа");await refresh.click();await expect(refresh).toBeEnabled();for(const path of ["/api/servers","/api/uptime/overview","/api/filters/rules","/api/filters/gre-tunnels","/api/internet-path/latest","/api/wireguard","/api/traffic/servers"])expect(calls).not.toContain(path);expect(calls.includes("/api/bgp/sessions")).toBe(bgp);const tile=page.getByRole("link").filter({hasText:"Префиксы BGP"});await expect(tile).toContainText(bgp?"321":"—");expect(calls).toContain("/api/vxlan") + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:true}});calls.push(path);return r.fulfill({json:path==="/api/vxlan"?{tunnels:[]}:path==="/api/events"?{events:[]}:path==="/api/bgp/catalog"?{sessions:[bgpSession()],failures:[]}:{}})});await page.goto("/dashboard");const refresh=page.getByRole("button",{name:"Обновить данные",exact:true});await expect(refresh).toBeEnabled();await expect(page.locator('[data-source="servers"]')).toContainText("Нет доступа");await refresh.click();await expect(refresh).toBeEnabled();for(const path of ["/api/servers","/api/uptime/overview","/api/filters/rules","/api/filters/gre-tunnels","/api/internet-path/latest","/api/wireguard","/api/traffic/servers"])expect(calls).not.toContain(path);expect(calls.includes("/api/bgp/catalog")).toBe(bgp);const tile=page.getByRole("link").filter({hasText:"Префиксы BGP"});await expect(tile).toContainText(bgp?"321":"—");expect(calls).toContain("/api/vxlan") }) diff --git a/tests/e2e/fixtures/bgp.ts b/tests/e2e/fixtures/bgp.ts new file mode 100644 index 00000000..94fd0196 --- /dev/null +++ b/tests/e2e/fixtures/bgp.ts @@ -0,0 +1,2 @@ +import type {BackendBgpSession} from "@/lib/bgp/response" +export function bgpSession(serverId=1,prefixesRx:number|null=321):BackendBgpSession{return {id:"*1",serverId,serverName:"Роутер "+serverId,serverSite:"TEST",serverCountry:"RU",name:"Сессия "+serverId,peerIp:"192.0.2."+serverId,remoteAs:64513,localAs:64512,localId:"192.0.2.100",remoteId:"192.0.2."+serverId,state:"Established",type:"eBGP",uptime:"1h",holdTime:90,keepalive:30,prefixesRx,prefixesTx:2,inputMessages:20,outputMessages:10,capabilities:[],lastError:null}}