From a28f871fd4be14ed41bd5f5589274d605ddbca43 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 23:00:02 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D1=83=D0=BB=D0=B5=D0=B9=20=D0=B2=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 6 +++++ docs/ux-ui-stage-12.md | 6 +++++ hooks/use-dashboard-live.ts | 40 ++++---------------------------- lib/dashboard-traffic.test.ts | 12 ++++++++++ lib/dashboard-traffic.ts | 14 +++++++++++ tests/e2e/dashboard-data.spec.ts | 8 +++++-- 7 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 lib/dashboard-traffic.test.ts create mode 100644 lib/dashboard-traffic.ts diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index bb2d20b7..af6dfbb1 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. Следующий блок12.2.2: traffic DTO/агрегация без выдуманных точек; затем остальные источники, производительность и сквозная регрессия. См. 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 принят локально: strict traffic DTO и агрегация без выдуманных точек. Следующий блок12.2.3: признаки реальных измерений в backend traffic API; затем остальные источники, производительность и сквозная регрессия. См. 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 b058ca4a..672fc104 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,12 @@ +## 12.2.2 — строгий traffic DTO и агрегация серий (2026-10-03) + +Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние. + +Проверки:21 browser dashboard/permissions,5 unit dashboard BGP/traffic/ACL, typecheck и ESLint прошли. ReUI chart/KPI композиции сохранены. Обнаруженное ограничение: backend buildTrafficFromSamples сам возвращает0 для ненаблюдавшихся интервалов; строгий reader не может отличить их от измеренного0 без расширения API. Далее12.2.3: добавить совместимые признаки наблюдений и использовать их на dashboard; затем servers/probes/overlay/Internet path. + ## 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 сохранена. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 5262206b..cc05ba82 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -37,3 +37,9 @@ Далее12.2.2: проверить traffic DTO/длины серий без искусственных60 точек и нулей; затем servers/probes/overlay/Internet path. Реальная телеметрия RouterOS остаётся внешней проверкой. + +## 12.2.2 — строгий traffic DTO и агрегация серий (2026-10-03) + +Перед публикацией traffic проверяются обязательный servers, уникальные id, конечные неотрицательные rates, совпадающие длины RX/TX и общей оси всех устройств. Ошибочный/missing/null DTO сохраняет последний успешный снимок и время; других источников ошибка не затрагивает. Удалена искусственная минимальная длина60 и дописывание недостающих точек нулями. Успешный пустой каталог даёт пустую историю, существующий chart показывает её штатное пустое состояние. + +Проверки:21 browser dashboard/permissions,5 unit dashboard BGP/traffic/ACL, typecheck и ESLint прошли. ReUI chart/KPI композиции сохранены. Обнаруженное ограничение: backend buildTrafficFromSamples сам возвращает0 для ненаблюдавшихся интервалов; строгий reader не может отличить их от измеренного0 без расширения API. Далее12.2.3: добавить совместимые признаки наблюдений и использовать их на dashboard; затем servers/probes/overlay/Internet path. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 5bfe3115..f6c8e1f2 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 {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic" import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp" import {dashboardRequestPermission} from "@/lib/dashboard-permissions" async function dashboardRequest(path:string,work:()=>Promise,method="GET"):Promise{ @@ -117,14 +118,6 @@ interface InternetPathSnapshotPayload { wanRuntimeByHomeId: Record } -interface TrafficServerRow { - id: string - rxNow: number - txNow: number - rxSeries: number[] - txSeries: number[] -} - export type OverlayKind = "gre" | "wg" | "vxlan" export interface OverlayItem { @@ -204,18 +197,6 @@ function mapBackendToServer(s: BackendServerRow): Server { } } -function sumSeries(rows: TrafficServerRow[], key: "rxSeries" | "txSeries"): number[] { - const len = Math.max(60, ...rows.map((r) => r[key].length), 0) - const out = Array.from({ length: len }, () => 0) - for (const row of rows) { - const series = row[key] - for (let i = 0; i < len; i++) { - out[i] += series[i] ?? 0 - } - } - return out -} - function mockOverlayItems(): OverlayItem[] { const gre: OverlayItem[] = mockGreTunnels.map((t) => ({ id: JSON.stringify(["gre", t.serverId, t.id]), @@ -253,12 +234,7 @@ export function useDashboardLive() { const [liveServers, setLiveServers] = useState(null) const [overlayItems, setOverlayItems] = useState(null) const [bgp, setBgp] = useState(null) - const [trafficSeries, setTrafficSeries] = useState<{ - rx: number[] - tx: number[] - rxNow: number - txNow: number - } | null>(null) + const [trafficSeries, setTrafficSeries] = useState(null) const [recentEvents, setRecentEvents] = useState([]) const [eventsError, setEventsError] = useState(null) const [eventsLoading, setEventsLoading] = useState(false) @@ -298,7 +274,7 @@ export function useDashboardLive() { apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), apiFetch<{ tunnels?: ApiGreTunnelRow[] }>("/api/filters/gre-tunnels"), dashboardRequest("/api/wireguard",()=>listWireGuard(backendUrl)), - apiFetch<{ servers?: TrafficServerRow[] }>("/api/traffic/servers?range=1h"), + apiFetch("/api/traffic/servers?range=1h").then(parseDashboardTraffic), apiFetch<{ tunnels?: Array<{ id: string; serverId: string; name: string; status: OverlayItem["status"] }> }>("/api/vxlan"), ]) @@ -359,15 +335,7 @@ export function useDashboardLive() { ...(vxRes.status === "fulfilled" ? vxItems : previous?.filter(item => item.kind === "vxlan") ?? []), ]) - if (trafficRes.status === "fulfilled") { - const rows = trafficRes.value.servers ?? [] - setTrafficSeries({ - rx: sumSeries(rows, "rxSeries"), - tx: sumSeries(rows, "txSeries"), - rxNow: rows.reduce((n, r) => n + (r.rxNow ?? 0), 0), - txNow: rows.reduce((n, r) => n + (r.txNow ?? 0), 0), - }) - } + if (trafficRes.status === "fulfilled") setTrafficSeries(trafficRes.value) const greMapped = greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : [] diff --git a/lib/dashboard-traffic.test.ts b/lib/dashboard-traffic.test.ts new file mode 100644 index 00000000..0960a955 --- /dev/null +++ b/lib/dashboard-traffic.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {parseDashboardTraffic} from "./dashboard-traffic" +const row={id:"1",rxNow:2,txNow:1,rxSeries:[1,2],txSeries:[0,1]} +test("traffic sums aligned observed series and does not invent sixty samples",()=>{ + assert.deepEqual(parseDashboardTraffic({servers:[row,{...row,id:"2"}]}),{rx:[2,4],tx:[0,2],rxNow:4,txNow:2}) + assert.deepEqual(parseDashboardTraffic({servers:[]}),{rx:[],tx:[],rxNow:0,txNow:0}) + assert.deepEqual(parseDashboardTraffic({servers:[{...row,rxSeries:[],txSeries:[]}]}),{rx:[],tx:[],rxNow:2,txNow:1}) +}) +test("missing, duplicate, null, invalid, misaligned and overflowing traffic cannot replace a snapshot",()=>{ + for(const raw of [{},{servers:[row,row]},{servers:[{...row,rxNow:null}]},{servers:[{...row,txNow:-1}]},{servers:[{...row,rxSeries:[null,1]}]},{servers:[{...row,txSeries:[1]}]},{servers:[row,{...row,id:"2",rxSeries:[],txSeries:[]}]},{servers:[{...row,rxNow:Number.MAX_VALUE},{...row,id:"2",rxNow:Number.MAX_VALUE}]}])assert.throws(()=>parseDashboardTraffic(raw)) +}) diff --git a/lib/dashboard-traffic.ts b/lib/dashboard-traffic.ts new file mode 100644 index 00000000..69446caf --- /dev/null +++ b/lib/dashboard-traffic.ts @@ -0,0 +1,14 @@ +import {z} from "zod" +const rate=z.number().finite().nonnegative() +const row=z.object({id:z.string().trim().min(1),rxNow:rate,txNow:rate,rxSeries:z.array(rate),txSeries:z.array(rate)}) +const payload=z.object({servers:z.array(row)}).refine(data=>new Set(data.servers.map(r=>r.id)).size===data.servers.length&&data.servers.every(r=>r.rxSeries.length===r.txSeries.length&&r.rxSeries.length===data.servers[0].rxSeries.length)) +export interface DashboardTraffic {rx:number[];tx:number[];rxNow:number;txNow:number} +/** API series share an index axis; unequal series cannot be completed with assumed zero. */ +export function parseDashboardTraffic(raw:unknown):DashboardTraffic { + const parsed=payload.safeParse(raw) + if(!parsed.success)throw Error("Некорректные данные трафика: проверьте счётчики и согласованность серий") + const rows=parsed.data.servers,length=rows[0]?.rxSeries.length??0 + const total={rx:Array.from({length},(_,i)=>rows.reduce((sum,r)=>sum+r.rxSeries[i],0)),tx:Array.from({length},(_,i)=>rows.reduce((sum,r)=>sum+r.txSeries[i],0)),rxNow:rows.reduce((sum,r)=>sum+r.rxNow,0),txNow:rows.reduce((sum,r)=>sum+r.txNow,0)} + if(![total.rxNow,total.txNow,...total.rx,...total.tx].every(Number.isFinite))throw Error("Суммарный трафик превышает допустимый диапазон") + return total +} diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 7c187b57..3f3e2b10 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -4,7 +4,7 @@ import { serverReadSchema } from "../../packages/contracts/src/servers" 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: () => {} } + const control = { failed: initiallyFailed, partial: false, prefixes: 321 as number|null, trafficPayload: undefined as unknown, 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 @@ -33,7 +33,7 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1, initi : path === "/api/filters/gre-tunnels" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] } : path === "/api/wireguard" ? { interfaces: [], peers: [] } : path === "/api/vxlan" ? { tunnels: [] } - : path === "/api/traffic/servers" ? { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500] }] } + : path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500] }] } : path === "/api/internet-path/latest" ? { snapshot: control.fallback ? null : snapshot } : path === "/api/uptime/speed-probes" ? { probes: [] } : path === "/api/events" ? { events: [{ id: "e1", title: "Событие сохранено", message: "Тестовое событие", level: "info", sourceModule: "system", eventType: "test", createdAt: "2026-09-20T00:00:00Z", entityId: null, entityType: null, payload: {} }] } : {} @@ -216,3 +216,7 @@ for(const unknown of ["prefix","state"])test("Dashboard BGP accepts observed unk 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("—"); }) + +for(const broken of ["missing","duplicate","counter","null","length"])test("Dashboard traffic rejects unconfirmed totals and keeps last good/"+broken,async({page})=>{ + const control=await setup(page);const source=page.locator('[data-source="traffic"]');await expect(refresh(page)).toBeEnabled();await expect(source.locator("time")).toHaveCount(1);const receivedAt=await source.locator("time").getAttribute("datetime");const rate={id:"1",rxNow:1000,txNow:500,rxSeries:[1000],txSeries:[500]};control.trafficPayload=broken==="missing"?{}:broken==="duplicate"?{servers:[rate,rate]}:broken==="counter"?{servers:[{...rate,rxNow:-1}]}:broken==="null"?{servers:[{...rate,rxNow:null}]}:{servers:[{...rate,txSeries:[]}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(page.getByText("RX 1.00 Гбит/с",{exact:true})).toBeVisible();await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");control.trafficPayload={servers:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible();await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible(); +})