fix(uptime): сохранить неизвестную скорость и проверить подтверждение теста
This commit is contained in:
1 parent
1904c86657
commit
9d5959601f
10 files changed
+160
-58
No files matched your search
+19
-25
@@ -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<Record<string, string>>
|
||||
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<unknown>("/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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<string, unknown>[]) {
|
||||
function rate(row: Record<string, unknown>, 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 }
|
||||
}
|
||||
@@ -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<Record<string, string>>
|
||||
@@ -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<void>((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
|
||||
+1
-1
@@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
|
||||
@@ -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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
|
||||
|
||||
|
||||
@@ -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 отдельно.
|
||||
@@ -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"))
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<void>(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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user