diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 0658782f..2300cf52 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1,5 +1,7 @@ "use client" +import { parseSpeedRunResponse } from "@/lib/speed-run-response" + import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes" import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts" @@ -1736,8 +1738,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId protocol: probe.protocol, direction: probe.direction, durationSec, - txAvgMbps: 0, - rxAvgMbps: 0, + txAvgMbps: null, + rxAvgMbps: null, status: "running" as const, command: buildSpeedCommand(probe), lines: ["status: running..."], @@ -1755,26 +1757,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId direction: probe.direction, durationSec, } - const res = await apiFetch<{ - result: { - txAvgMbps: number - rxAvgMbps: number - raw?: Array> - afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } - srcAddress?: string | null - dstAddress?: string | null - srcInterfaceAddress?: string | null - dstInterfaceAddress?: string | null - } - }>("/api/uptime/speed-test", { + const res = await apiFetch("/api/uptime/speed-test", { method: "POST", // Includes interface lookups, bandwidth-test, post-test pause and Ping. signal: AbortSignal.timeout(120_000), body: JSON.stringify(payload), }) if (context !== saveContext.current) return - const ap = res.result.afterBtPing - const lines = (res.result.raw ?? []).flatMap((row) => + const result = parseSpeedRunResponse(res, runId) + const tx = result.txAvgMbps === null ? null : Math.round(result.txAvgMbps) + const rx = result.rxAvgMbps === null ? null : Math.round(result.rxAvgMbps) + const ap = result.afterBtPing + const lines = (result.raw ?? []).flatMap((row) => Object.entries(row).map(([k, v]) => `${k}: ${String(v)}`), ) const pingNote = ap @@ -1784,16 +1778,16 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId : "" setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, - txAvgMbps: Math.round(res.result.txAvgMbps), - rxAvgMbps: Math.round(res.result.rxAvgMbps), + txAvgMbps: tx, + rxAvgMbps: rx, status: "done", afterBtPing: ap ?? null, - srcAddress: res.result.srcAddress ?? null, - dstAddress: res.result.dstAddress ?? null, - srcInterfaceAddress: res.result.srcInterfaceAddress ?? null, - dstInterfaceAddress: res.result.dstInterfaceAddress ?? null, + srcAddress: result.srcAddress ?? null, + dstAddress: result.dstAddress ?? null, + srcInterfaceAddress: result.srcInterfaceAddress ?? null, + dstInterfaceAddress: result.dstInterfaceAddress ?? null, lines: [ - ...(lines.length ? lines : [`tx-total-average: ${Math.round(res.result.txAvgMbps)}Mbps`, `rx-total-average: ${Math.round(res.result.rxAvgMbps)}Mbps`]), + ...(lines.length ? lines : [`tx-total-average: ${tx === null ? "—" : `${tx}Mbps`}`, `rx-total-average: ${rx === null ? "—" : `${rx}Mbps`}`]), ...(pingNote ? [pingNote] : []), ], }) : r)) @@ -1801,8 +1795,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({ ...p, lastRunAt: ts, - lastTxAvgMbps: Math.round(res.result.txAvgMbps), - lastRxAvgMbps: Math.round(res.result.rxAvgMbps), + lastTxAvgMbps: tx, + lastRxAvgMbps: rx, lastStatus: "done", lastError: "", lastPingRttMs: ap?.rttMs ?? null, diff --git a/backend/src/services/bandwidth-test-rates.test.ts b/backend/src/services/bandwidth-test-rates.test.ts new file mode 100644 index 00000000..bd5d67d8 --- /dev/null +++ b/backend/src/services/bandwidth-test-rates.test.ts @@ -0,0 +1,22 @@ +import {test} from "node:test" +import assert from "node:assert/strict" +import {parseBandwidthRateMbps,readBandwidthTestRates} from "./bandwidth-test-rates.js" +test("native units preserve decimal and explicit zero; absence and malformed rate are unknown",()=>{ + for(const raw of [undefined,null,"","bad","-1Mbps",false,"Infinity","5MiB/s"])assert.equal(parseBandwidthRateMbps(raw),null) + assert.equal(parseBandwidthRateMbps("0bps"),0);assert.equal(parseBandwidthRateMbps("150.25Mbps"),150.25) + assert.equal(parseBandwidthRateMbps("125kbps"),0.125);assert.equal(parseBandwidthRateMbps(1000000),1) + assert.equal(parseBandwidthRateMbps("1.5Gbps"),1500) +}) +test("explicit total-average zero wins over nonzero samples",()=>{ + const result=readBandwidthTestRates([{"tx-current":"20Mbps"},{"tx-total-average":"0bps","rx-total-average":"150.25Mbps"}]) + assert.equal(result.txAvgMbps,0);assert.equal(result.rxAvgMbps,150.25) +}) +test("one-direction test leaves the other average and curve unknown",()=>{ + const result=readBandwidthTestRates([{"rx-current":"10Mbps"},{"rx-total-average":"20Mbps"}]) + assert.equal(result.txAvgMbps,null);assert.deepEqual(result.txSeries,[]);assert.equal(result.rxAvgMbps,20) + assert.deepEqual(readBandwidthTestRates([]),{txAvgMbps:null,rxAvgMbps:null,txSeries:[],rxSeries:[]}) +}) +test("fallback uses only valid samples and never fabricates zero",()=>{ + const result=readBandwidthTestRates([{"tx-current":"bad"},{"tx-current":"10Mbps"},{"tx-current":"20Mbps"}]) + assert.equal(result.txAvgMbps,15);assert.deepEqual(result.txSeries,[10,20]);assert.equal(result.rxAvgMbps,null) +}) diff --git a/backend/src/services/bandwidth-test-rates.ts b/backend/src/services/bandwidth-test-rates.ts new file mode 100644 index 00000000..adce846b --- /dev/null +++ b/backend/src/services/bandwidth-test-rates.ts @@ -0,0 +1,30 @@ +/** RouterOS rates are bits/s unless a decimal SI prefix is present. */ +export function parseBandwidthRateMbps(raw: unknown): number | null { + if (typeof raw !== "string" && typeof raw !== "number") return null + const match = String(raw).trim().toLowerCase().match(/^(\d+(?:\.\d+)?|\.\d+)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/) + if (!match) return null + const factor = match[2] === "g" ? 1000 : match[2] === "m" ? 1 : match[2] === "k" ? 0.001 : 0.000001 + const value = Number(match[1]) * factor + return Number.isFinite(value) && value >= 0 ? value : null +} + +export function readBandwidthTestRates(rows: readonly Record[]) { + function rate(row: Record, keys: string[]) { + for (const key of keys) { + const value = parseBandwidthRateMbps(row[key]) + if (value !== null) return value + } + return null + } + function series(side: "tx" | "rx") { + return rows.flatMap(row => { + const value = rate(row, [`${side}-current`, `${side}-10-second-average`, `${side}-total-average`]) + return value === null ? [] : [value] + }) + } + const txSeries = series("tx"), rxSeries = series("rx"), last = rows.at(-1) ?? {} + const average = (side: "tx" | "rx", values: number[]) => + rate(last, [`${side}-total-average`, `${side}-10-second-average`]) ?? + (values.length ? values.reduce((sum, value) => sum + value / values.length, 0) : null) + return { txAvgMbps: average("tx", txSeries), rxAvgMbps: average("rx", rxSeries), txSeries, rxSeries } +} diff --git a/backend/src/services/uptime-speed-service.ts b/backend/src/services/uptime-speed-service.ts index cbf7abad..edc3eacb 100644 --- a/backend/src/services/uptime-speed-service.ts +++ b/backend/src/services/uptime-speed-service.ts @@ -7,6 +7,7 @@ import type { RosIpAddress } from "../types/server.js" import { withBtestNodeLocks } from "./btest-node-locks.js" import { MikrotikClient } from "./mikrotik.js" import { parsePing } from "./uptime-collector.js" +import { readBandwidthTestRates } from "./bandwidth-test-rates.js" function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string { const name = iface.trim() @@ -26,24 +27,6 @@ function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string { return raw ? String(raw).split("/")[0]?.trim() ?? "" : "" } -function parseRateToMbps(raw: unknown): number { - const txt = String(raw ?? "").trim().toLowerCase() - if (!txt) return 0 - const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/) - if (m) { - const n = Number.parseFloat(m[1]) - const u = m[2] ?? "" - if (!Number.isFinite(n)) return 0 - if (u === "g") return n * 1000 - if (u === "m") return n - if (u === "k") return n / 1000 - return n / 1_000_000 - } - const n = Number.parseFloat(txt.replace(/[^\d.]/g, "")) - if (!Number.isFinite(n)) return 0 - return n > 10000 ? n / 1_000_000 : n -} - export interface SpeedTestRunParams { runId?: string probeId?: string @@ -68,8 +51,8 @@ export interface SpeedTestRunResult { protocol: "tcp" | "udp" direction: "transmit" | "receive" | "both" durationSec: number - txAvgMbps: number - rxAvgMbps: number + txAvgMbps: number | null + rxAvgMbps: number | null txSeries: number[] rxSeries: number[] raw: Array> @@ -123,17 +106,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams) direction, durationSec, }) - const txSeries = rows - .map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"])) - .filter((v) => Number.isFinite(v) && v >= 0) - const rxSeries = rows - .map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"])) - .filter((v) => Number.isFinite(v) && v >= 0) - const last = rows[rows.length - 1] ?? {} - const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"]) - const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"]) - const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0) - const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0) + const { txAvgMbps: txFinal, rxAvgMbps: rxFinal, txSeries, rxSeries } = readBandwidthTestRates(rows) await new Promise((resolve) => setTimeout(resolve, 3000)) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index e953e852..77c9c5d0 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.3.72 scope/units завершён: device-pair speed/WAN model/Ping interface пояснены,10browser/type/lint/build. Следующий12.3.73: nullable bandwidth parser/average selector и строгий nullable POST consumer; затем74terminal confirmation,75OSPF RX/TX mapping,76mobile pinned-column readable geometry. Команды/права не менять,12.5 отдельно. +Актуальное продолжение 2026-10-07: 12.3.73 завершён: nullable bandwidth producer/POST consumer, проверка runId, 16pure/71browser/8context и frontend/backend build. Следующий12.3.74: terminal confirmation RouterOS; затем75OSPF RX/TX mapping,76mobile pinned-column readable geometry. Команды/права не менять,12.5 отдельно. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index f529a867..4bfdea36 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.3.72 scope/units завершён: device-pair speed/WAN model/Ping interface пояснены,10browser/type/lint/build. Следующий12.3.73: nullable bandwidth parser/average selector и строгий nullable POST consumer; затем74terminal confirmation,75OSPF RX/TX mapping,76mobile pinned-column readable geometry. Команды/права не менять,12.5 отдельно. +Актуальное продолжение 2026-10-07: 12.3.73 завершён: nullable bandwidth producer/POST consumer, проверка runId, 16pure/71browser/8context и frontend/backend build. Следующий12.3.74: terminal confirmation RouterOS; затем75OSPF RX/TX mapping,76mobile pinned-column readable geometry. Команды/права не менять,12.5 отдельно. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 73b04f54..a62bc04e 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -808,3 +808,12 @@ CPU final:1000routes13.3ms;100000(10×100×100)49.7ms;100000(100×10×100)44.0ms 10browser (observed forward/reverse/null/zero,20/1000probes,1000WAN/100JH) прошли; TypeScript до dev, targeted lint и production build успешны. При noProbe измерения остаются неизвестными. Scope note находится один раз над таблицей, без повторения длинного предупреждения в каждой ячейке. Остаются73missing→0/nullable consumer,74native terminal status,75OSPF direction. Чтение frontend consumer подтвердило ещё Math.round(null)→0 и доверие POST без runtime DTO; фиксировать73 вместе с producer. Границы мобильного pinnedWAN и соседней metric cell требуют отдельного76geometry теста, а не только проверки первого столбца. Внешняя12.5 отдельно. + + +## 12.3.73 — неизвестная скорость и подтверждение POST + +2026-10-07. Backend parser возвращает null для отсутствующих/невалидных скоростей, переводит RouterOS SI bits/s в Мбит/с и сохраняет явный total-average 0 вместо замены средним. Серии включают только валидные значения; отсутствующие samples не получают нулей, временная геометрия пропусков здесь не реализована. Поля БД уже nullable, миграции и переписывания исторических записей нет. Frontend проверяет ok/result/runId и nullable finite nonnegative rates до публикации ответа. Контекст проверяется раньше DTO, поздний ответ чужого источника игнорируется. Running averages null; Math.round(null) исключён. Ошибка сохраняет предыдущие probe averages и подтверждённую историю. + +7 pure parser/DTO +9 isolated scheduler journal tests прошли.71 main browser (uptime-links, speed-data, large-speed-history),8 context прошли; non-incremental TypeScript, targeted lint (9 существующих предупреждений uptime,0 errors), backend и frontend production build успешны. Первые два новых браузерных прогона обнаружили неверный KPI role selector и неоднозначность одинаковой подписи в истории/строке; selectors уточнены, финальный связанный профиль полностью passed. Historical large-speed-history-320 PNG восстановлен. + +Следующий74: native terminal status ещё не проверяется перед done/DB write.75OSPF direction и76mobile metric geometry остаются. DTO доказывает целостность ответа, но не факт выполнения на устройстве. Реальная RouterOS/PG приёмка12.5 отдельно. diff --git a/lib/speed-run-response.test.ts b/lib/speed-run-response.test.ts new file mode 100644 index 00000000..60fb2b6e --- /dev/null +++ b/lib/speed-run-response.test.ts @@ -0,0 +1,17 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { parseSpeedRunResponse } from "./speed-run-response" +const receipt = (tx: unknown, rx: unknown, runId = "run-1") => ({ ok: true, result: { runId, txAvgMbps: tx, rxAvgMbps: rx } }) +test("nullable result and measured zero stay distinct", () => { + assert.equal(parseSpeedRunResponse(receipt(null, 0), "run-1").txAvgMbps, null) + assert.equal(parseSpeedRunResponse(receipt(null, 0), "run-1").rxAvgMbps, 0) + assert.equal(parseSpeedRunResponse(receipt(0.25, 10), "run-1").txAvgMbps, 0.25) +}) +test("invalid or foreign receipts cannot publish measurements", () => { + for (const raw of [receipt(-1, 0), receipt("10", 0), receipt(NaN, 0), receipt(Infinity, 0), receipt(undefined, 0), receipt(10, 0, "other"), { result: receipt(10, 0).result }, { ok: false, result: receipt(10, 0).result }, null]) { + assert.throws(() => parseSpeedRunResponse(raw, "run-1"), /Результат операции неизвестен/) + } +}) +test("invalid post-test ping rejects the whole receipt", () => { + assert.throws(() => parseSpeedRunResponse({ ...receipt(10, 0), result: { ...receipt(10, 0).result, afterBtPing: { rttMs: null, lossPct: 101, error: null } } }, "run-1")) +}) diff --git a/lib/speed-run-response.ts b/lib/speed-run-response.ts new file mode 100644 index 00000000..195e3174 --- /dev/null +++ b/lib/speed-run-response.ts @@ -0,0 +1,24 @@ +import { z } from "zod" + +const metric = z.number().finite().nonnegative().nullable() +const address = z.string().nullable().optional() +const response = z.object({ + ok: z.literal(true), + result: z.object({ + runId: z.string().min(1), + txAvgMbps: metric, + rxAvgMbps: metric, + raw: z.array(z.record(z.string(), z.unknown())).optional(), + afterBtPing: z.object({ rttMs: metric, lossPct: z.number().finite().min(0).max(100).nullable(), error: z.string().nullable() }).optional(), + srcAddress: address, dstAddress: address, + srcInterfaceAddress: address, dstInterfaceAddress: address, + }), +}) + +export function parseSpeedRunResponse(raw: unknown, runId: string) { + const parsed = response.safeParse(raw) + if (!parsed.success || parsed.data.result.runId !== runId) { + throw new Error("Некорректное подтверждение speed-test. Результат операции неизвестен") + } + return parsed.data.result +} diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index e109ed1f..d45c13f4 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -763,3 +763,36 @@ test("Ping catalog: malformed initial/refresh disables replacement, recovery and await page.getByRole("alertdialog").getByRole("button", { name: "Отмена", exact: true }).click() expect(control.writes).toEqual([]) }) + +for (const invalid of [false, true]) { + test(`Speed-test: nullable POST, ноль и ${invalid ? "чужой runId" : "невалидная скорость"}`, async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + await page.getByRole("button", { name: /Скорость/ }).click() + let calls = 0 + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route("**/api/uptime/speed-test", async route => { + calls++ + const { runId } = route.request().postDataJSON() + if (calls === 1) await pending + await route.fulfill({ json: { ok: true, result: { runId: calls === 2 && invalid ? "foreign-run" : runId, txAvgMbps: calls === 2 && !invalid ? "999" : null, rxAvgMbps: calls === 2 ? 999 : 0 } } }) + }) + const run = page.getByRole("button", { name: "Запустить", exact: true }) + const summary = page.locator('[aria-label="Сводка speed-проб"]') + await run.click() + await expect(page.getByText("Тест выполняется…", { exact: true })).toBeVisible() + await expect(summary).not.toContainText("0 Мбит/с") + release() + await expect(run).toBeEnabled() + await expect(run.locator("xpath=../../..").getByText("Неизвестно", { exact: true })).toBeVisible() + await expect(run.locator("xpath=../../..").getByText("0 Мбит/с", { exact: true })).toBeVisible() + await expect(summary).toContainText("1 run") + await run.click() + await expect(page.getByRole("alert").filter({ hasText: "Некорректное подтверждение speed-test" })).toBeVisible() + await expect(run).toBeEnabled() + await expect(summary).toContainText("1 run") + await expect(summary).not.toContainText("999 Мбит/с") + expect(calls).toBe(2) + }) +}