fix(traffic): сохранять точность объёмов и обозначать бинарные единицы

This commit is contained in:
Denozordec committed 2026-10-12 21:59:50 +07:00
1 parent 2358ecef60
commit 2b3d583e3b
29 files changed
+1452 -39

No files matched your search

+10 -6
View File
@@ -19,7 +19,7 @@ import {
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
import { fmtGiB, fmtRate } from "@/lib/fmt-rate"
import { useDataSource } from "@/lib/data-source"
import { useTrafficLive } from "@/hooks/use-traffic-live"
import { useFlowLive } from "@/hooks/use-flow-live"
@@ -86,6 +86,7 @@ function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): st
// ─── data model ───────────────────────────────────────────────────────────────
interface BoundIfaceTraffic {
volumeUnit?: "GiB"
observations?: TrafficObservationMeta
id: string
bindingId: string
@@ -113,6 +114,7 @@ interface BoundIfaceTraffic {
}
interface ServerTraffic {
volumeUnit?: "GiB"
observations?: TrafficObservationMeta
id: string
name: string
@@ -132,6 +134,7 @@ interface ServerTraffic {
}
interface UserTraffic {
volumeUnit?: "GiB"
observations?: TrafficObservationMeta
id: string
login: string
@@ -150,6 +153,7 @@ interface UserTraffic {
}
interface LiveTrafficServer {
volumeUnit?: "GiB"
observations?: TrafficObservationMeta
id: string
name: string
@@ -497,8 +501,8 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
</div>
</div>
<div className="shrink-0 text-right leading-tight text-[10px] text-muted-foreground w-[72px]">
<p className="tabular-nums">{fmtGB(c.rxTotal)}</p>
<p className="tabular-nums">{fmtGB(c.txTotal)}</p>
<p className="tabular-nums">{fmtGiB(c.rxTotal)}</p>
<p className="tabular-nums">{fmtGiB(c.txTotal)}</p>
</div>
<span className={cn(
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium tabular-nums",
@@ -555,18 +559,18 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
rxTotal: number|null; txTotal: number|null; rxSeries: (number|null)[]; txSeries: (number|null)[]
}) {
return (
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4 [&>div]:min-w-0 [&_svg]:max-w-full">
<div role="group" aria-label="Учтённый объём по измерениям" className="mt-4 pt-4 border-t grid grid-cols-2 gap-4 [&>div]:min-w-0 [&_svg]:max-w-full">
<div>
<p className="text-xs text-muted-foreground mb-1">Учтено RX по замерам</p>
<p className="text-lg font-semibold tabular-nums">
{fmtGB(rxTotal)}
{fmtGiB(rxTotal)}
</p>
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Учтено TX по замерам</p>
<p className="text-lg font-semibold tabular-nums">
{fmtGB(txTotal)}
{fmtGiB(txTotal)}
</p>
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
</div>
+1 -1
View File
@@ -19,7 +19,7 @@
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts",
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
+2
View File
@@ -28,6 +28,7 @@ import {
} from "../services/traffic-users.js"
interface TrafficServerDto {
volumeUnit: "GiB"
observations?:TrafficObservations
id: string
name: string
@@ -95,6 +96,7 @@ function buildServerTraffic(
): TrafficServerDto {
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface,undefined,true)
return {
volumeUnit: "GiB",
id: String(s.id),
name: s.name || s.host,
site: s.site || "—",
@@ -58,6 +58,7 @@ const samples: TrafficSampleLike[] = [
const built = buildTrafficFromSamples(samples, start, end)
assert.ok(built.rxPeak > 0, "peak RX from delta")
assert.equal(built.rxNow, 1, "last valid delta after reset skip")
assert.equal(built.rxTotalGiB,3_750_000/1024**3,"only observed positive delta counts; reset is not volume")
assert.ok(built.rxSeries.some((v) => v > 0), "bucket series not flat")
assert.ok(built.rxPeak <= 1.1, "lo excluded from peak")
+2 -4
View File
@@ -216,8 +216,8 @@ export function buildTrafficFromSamples(
txNow: last?.tx ?? 0,
rxPeak,
txPeak,
rxTotalGiB: Number((rxBytesDelta / (1024 ** 3)).toFixed(1)),
txTotalGiB: Number((txBytesDelta / (1024 ** 3)).toFixed(1)),
rxTotalGiB: rxBytesDelta / (1024 ** 3),
txTotalGiB: txBytesDelta / (1024 ** 3),
sessions,
rxSeries,
txSeries,
@@ -268,8 +268,6 @@ export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeri
acc.txSeries[i] += p.txSeries[i] ?? 0
}
}
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
return parts.every(p => p.observations) ? { ...acc, observations: mergeTrafficObservations(parts.map(p => p.observations!)) } : acc
}
+4 -8
View File
@@ -1,4 +1,3 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { listUsers } from "../modules/users/service/users-service.js"
@@ -13,6 +12,7 @@ import { applyTrafficFreshness } from "./traffic-freshness.js"
import type { TrafficObservations } from "./traffic-rate.js"
export interface BoundIfaceTrafficDto {
volumeUnit: "GiB"
observations?: TrafficObservations
id: string
bindingId: string
@@ -40,6 +40,7 @@ export interface BoundIfaceTrafficDto {
}
export interface UserTrafficDto {
volumeUnit: "GiB"
observations?: TrafficObservations
id: string
login: string
@@ -59,6 +60,7 @@ export interface UserTrafficDto {
function seriesFromBuilt(built: BuiltTrafficSeries) {
return {
volumeUnit: "GiB" as const,
observations: built.observations,
rxNow: built.rxNow,
txNow: built.txNow,
@@ -71,12 +73,6 @@ function seriesFromBuilt(built: BuiltTrafficSeries) {
}
}
async function serverStatus(serverId: number): Promise<"online" | "offline"> {
const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
if (!row?.enabled) return "offline"
return "online"
}
export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): Promise<UserTrafficDto[]> {
const sinceIso = new Date(rangeStartMs).toISOString()
const users = await listUsers()
@@ -122,7 +118,7 @@ export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: num
serverSite: b.serverSite,
serverCountry: b.serverCountry,
...seriesFromBuilt(built),
status: running && (await serverStatus(b.serverId)) === "online" ? "online" : "offline",
status: running && server?.enabled ? "online" : "offline",
})
}
@@ -0,0 +1,40 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { pool, closePool } from "../db/index.js"
import { initDatabase } from "../db/bootstrap.js"
import trafficRoutes from "../routes/traffic.js"
const database=process.env.MMAPP_OWN_SQL_DB
if(!database)console.log("traffic volume SQL: skipped; explicit owned database required")
else {
assert(/^mm_accept_166_\d+$/.test(database));assert.equal(new URL(process.env.DATABASE_URL!).pathname,"/"+database)
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name,database)
const prefix="own-volume180-"+Date.now()+"-",userId=prefix+"user",at=new Date(Date.now()-5000).toISOString(),updatedAt=new Date(Date.now()-3600_000).toISOString()
let serverId:number|undefined,cases=0
const app=Fastify();await app.register(trafficRoutes)
const get=async(path:string)=>{const r=await app.inject({method:"GET",url:path});assert.equal(r.statusCode,200);return r.json()}
const put=async(name:string,offset:number,bytes:number)=>pool.query("INSERT INTO traffic_samples(server_id,interface_name,sampled_at,rx_bytes,tx_bytes,flags) VALUES($1,$2,$3,$4,$4,5)",[serverId,name,new Date(Date.parse(at)+offset).toISOString(),bytes])
try {
await initDatabase()
serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,updated_at) VALUES($1,'127.0.0.1',1,false,true,'fixture','fixture',$2) RETURNING id",[prefix+"server",updatedAt])).rows[0].id)
await pool.query("INSERT INTO traffic_settings(id,enabled,interval_sec) VALUES(1,true,30) ON CONFLICT(id) DO UPDATE SET enabled=true,interval_sec=30")
await pool.query("INSERT INTO traffic_collection_state(server_id,server_updated_at,last_attempt_at,last_succeeded_at,status) VALUES($1,$2,$3,$3,'ok')",[serverId,updatedAt,at])
await pool.query("INSERT INTO app_users(id,login,name) VALUES($1,$2,'Volume fixture')",[userId,prefix+"login"])
await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type) VALUES($1,$2,$3,'ether1','ether')",[prefix+"binding",userId,serverId])
for(const bytes of[1,1024,1024**2,1024**3]){
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await put("ether1",-30_000,0);await put("ether1",0,bytes)
const s=(await get(`/traffic/servers/${serverId}?range=1h`)).server
assert.equal(s.volumeUnit,"GiB");assert.equal(s.rxTotal,bytes/1024**3);assert.equal(s.observations.rxNow,true)
const user=(await get("/traffic/users?range=1h")).users.find((u:{id:string})=>u.id===userId)
assert.equal(user.volumeUnit,"GiB");assert.equal(user.rxTotal,s.rxTotal)
const iface=(await get("/traffic/bound-interfaces?range=1h")).interfaces.find((i:{userId:string})=>i.userId===userId)
assert.equal(iface.volumeUnit,"GiB");assert.equal(iface.rxTotal,s.rxTotal);cases++
}
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await pool.query("DELETE FROM user_interface_bindings WHERE user_id=$1",[userId])
for(let i=0;i<100;i++){const name=`ether${i}`;await put(name,-30_000,0);await put(name,0,1024**2);await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type) VALUES($1,$2,$3,$4,'ether')",[prefix+"binding"+i,userId,serverId,name])}
let s=(await get(`/traffic/servers/${serverId}?range=1h`)).server;assert.equal(s.rxTotal,100/1024)
const user=(await get("/traffic/users?range=1h")).users.find((u:{id:string})=>u.id===userId);assert.equal(user.rxTotal,100/1024);cases++
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await put("ether1",-90_000,0);await put("ether1",-60_000,1024**2);await put("ether1",-30_000,512);await put("ether1",0,1024)
s=(await get(`/traffic/servers/${serverId}?range=1h`)).server;assert.equal(s.rxTotal,(1024**2+512)/1024**3);cases++
console.log(`traffic volume owned SQL: ${cases} cases; exact bytes/binary unit/server/user/binding/100-part aggregate/reset passed`)
}finally{await app.close();await pool.query("DELETE FROM app_users WHERE id=$1",[userId]);if(serverId!==undefined){await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await pool.query("DELETE FROM servers WHERE id=$1",[serverId])}await closePool()}
}
+23 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
@@ -696,7 +696,7 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2070,3 +2070,24 @@ Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/sour
[Настоящие CHR SSE](ux-ui/acceptance/traffic-live-native-2026-10-12.json) получили первый полный sample на обоих sourceId1/2, затем streams отменены: native interface GET + monitor-traffic read-only POST. Пример краткого наблюдения194 RX0.031584/TX0.002016Mbps,104 RX/TX0.002112Mbps; это не SLA/capacity/WAN proof. Пароли/конфигурация не менялись, messages0. [Production browser1440/320](ux-ui/acceptance/traffic-live-deployed-2026-10-12.json): actual live labels/receipt provenance,3APIs200,no overflow/pageerrors. Lab AUTH_REQUIRED=false; JWT/RBAC scopes не объявлены полностью принятыми по этой smoke.
Следующий180 — volume precision/binary units. Read-only baseline уже показал: measured1KiB/1MiB API round to0;100×1MiB также aggregate0;1GiB подписан1.0GB. Baseline сохранён в ignored tmp/volume180-baseline.json и пока не выдан за исправление. Sparse intervals/peer completeness/identity recreation/restore/multi-instance/scale и внешние integrations остаются12.5. Локальный коммит без push.
## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12)
Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода.
DTO серверов, пользователей и привязок объявляет volumeUnit=GiB. Frontend принимает эту единицу явно, absent unit оставляет volume — без потери known speed/history; unsupportedGB marker отклоняет DTO. fmtGiB отображает Б/КиБ/МиБ/ГиБ/ТиБ/ПиБ, positive ниже1B как<1B, true0 как0B. Число округляется только при форматировании. Existing rate formatter остаётся decimal Кбит/Мбит/Гбит, binary объёмы не смешиваются с decimal rates. TotalsRow получил semantic group label. ReUI layout/theme/components сохранены. Удалён N+1 server-status SELECT на каждую binding: используется уже прочитанный catalog snapshot.
| Состояние | Схема | Пример |
| --- | --- | --- |
| Small positive bytes | bytes → exact GiB DTO → подходящая binary unit | [ПК](ux-ui/screens/traffic-volume-1440-2026-10-12.png), [320](ux-ui/screens/traffic-volume-320-2026-10-12.png), API-fixture component capture |
| Old response without unit | unknown unit → —, speeds/history retained | E2E actual API path fixture |
Backend formulas/volume assertion (known positive delta counts; reset does not), frontend unit boundary/unknown/zero/projection, TypeScript/backend build/ESLint passed.18 E2E passed36.3s (2new volume+10detail+6mobile), screenshot delta2 passed10.5s не новые cases; final widget captures просмотрены. Новые positive fixtures явно объявляют GiB. Existing user8 GRE/VXLAN screenshots не тронуты.
Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP SSE+6new volume SQL cases, без SQL skip. Новые6 — storage/API GET для1B/1KiB/1MiB/1GiB на server/user/binding;100-part aggregate100MiB; reset lower-bound1MiB+512B. Seeded marker5 rows — собственные SQL fixtures, не физические device counts; отсутствие RouterOS calls в volume-only case не объявлено native acceptance. OwnDB mm_accept_166_1791816469068/ограниченная role/container удалены, absence verified. [SQL](ux-ui/acceptance/traffic-volume-sql-2026-10-12.json).
[Source452/522](ux-ui/acceptance/traffic-volume-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-volume-compiled-2026-10-12.json) SHA256 parity passed. Обе production builds прошли. Frontend ux-volume180 staged with backend ux-live179 [interop](ux-ui/acceptance/traffic-volume-interop-2026-10-12.json): volumes без unit unknown до backend update. Затем both ux-volume180/version1.0.0-test.volume180 [runtime](ux-ui/acceptance/traffic-volume-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-live179; no secret env copy/new migration. Старую вкладку следует перезагрузить для новых unit labels.
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-12
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
@@ -371,6 +371,6 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+22 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,27 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12)
Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода.
DTO серверов, пользователей и привязок объявляет volumeUnit=GiB. Frontend принимает эту единицу явно, absent unit оставляет volume — без потери known speed/history; unsupportedGB marker отклоняет DTO. fmtGiB отображает Б/КиБ/МиБ/ГиБ/ТиБ/ПиБ, positive ниже1B как<1B, true0 как0B. Число округляется только при форматировании. Existing rate formatter остаётся decimal Кбит/Мбит/Гбит, binary объёмы не смешиваются с decimal rates. TotalsRow получил semantic group label. ReUI layout/theme/components сохранены. Удалён N+1 server-status SELECT на каждую binding: используется уже прочитанный catalog snapshot.
| Состояние | Схема | Пример |
| --- | --- | --- |
| Small positive bytes | bytes → exact GiB DTO → подходящая binary unit | [ПК](ux-ui/screens/traffic-volume-1440-2026-10-12.png), [320](ux-ui/screens/traffic-volume-320-2026-10-12.png), API-fixture component capture |
| Old response without unit | unknown unit → —, speeds/history retained | E2E actual API path fixture |
Backend formulas/volume assertion (known positive delta counts; reset does not), frontend unit boundary/unknown/zero/projection, TypeScript/backend build/ESLint passed.18 E2E passed36.3s (2new volume+10detail+6mobile), screenshot delta2 passed10.5s не новые cases; final widget captures просмотрены. Новые positive fixtures явно объявляют GiB. Existing user8 GRE/VXLAN screenshots не тронуты.
Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP SSE+6new volume SQL cases, без SQL skip. Новые6 — storage/API GET для1B/1KiB/1MiB/1GiB на server/user/binding;100-part aggregate100MiB; reset lower-bound1MiB+512B. Seeded marker5 rows — собственные SQL fixtures, не физические device counts; отсутствие RouterOS calls в volume-only case не объявлено native acceptance. OwnDB mm_accept_166_1791816469068/ограниченная role/container удалены, absence verified. [SQL](ux-ui/acceptance/traffic-volume-sql-2026-10-12.json).
[Source452/522](ux-ui/acceptance/traffic-volume-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-volume-compiled-2026-10-12.json) SHA256 parity passed. Обе production builds прошли. Frontend ux-volume180 staged with backend ux-live179 [interop](ux-ui/acceptance/traffic-volume-interop-2026-10-12.json): volumes без unit unknown до backend update. Затем both ux-volume180/version1.0.0-test.volume180 [runtime](ux-ui/acceptance/traffic-volume-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-live179; no secret env copy/new migration. Старую вкладку следует перезагрузить для новых unit labels.
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
## 12.5.179 — достоверный live-трафик, остановка и переподключение (2026-10-12)
parseMonitorTraffic больше не превращает empty/missing/malformed/negative/prefix values в0. Требуются именованные уникальные интерфейсы и обе конечные неотрицательные скорости; для aggregate — полный expected set актуальных активных интерфейсов, loopback исключён. Duplicate/wrong/missing interface и неполный ответ дают error. Настоящий measured0 сохраняется. Дельты и Mbps больше не округляются до хранения/передачи: positive1byte/30s не исчезает; legacy bigint rxBps/txBps округляются только при insert, read consumers используют counters. sum snapshot сохраняет полную точность. UI positive меньше разрешения отображается «<1 Кбит/с», а не fake idle0. Это не подтверждение максимальной производительности интерфейса.
+24 -3
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-12
Это актуальная матрица после 12.5.179. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Это актуальная матрица после 12.5.180. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-live179`, frontend `ux-live179`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-volume180`, frontend `ux-volume180`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
| Раздел | Подтверждено | Осталось / условие завершения |
| --- | --- | --- |
@@ -18,7 +18,7 @@
| Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята |
| Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты |
| Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения |
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; history178/legacy provenance/current TTL/manual truth/SQL9+14/35unique E2E/native GET/deployed passed; live179 complete/disable-config/bounded fallback/ownSQL10/nativeSSE2/liveUI2 passed; volume precision/units180 next; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию |
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; history178/legacy provenance/current TTL/manual truth/SQL9+14/35unique E2E/native GET/deployed passed; live179 complete/disable-config/bounded fallback/ownSQL10/nativeSSE2/liveUI2 passed; volume180 exact/unit marker/ownSQL6/18E2E/API/liveUI/deployed passed; binding status freshness181 next; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию |
Доказательства и точные ограничения: [полная история этапа12](ux-ui-stage-12.md), [внешний протокол](ux-ui-external-acceptance.md), [BGP93](ux-ui/acceptance/bgp-failover-2026-10-10.json), [паспорт стенда](ux-ui-test-server.md). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
@@ -985,3 +985,24 @@ Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/sour
[Настоящие CHR SSE](ux-ui/acceptance/traffic-live-native-2026-10-12.json) получили первый полный sample на обоих sourceId1/2, затем streams отменены: native interface GET + monitor-traffic read-only POST. Пример краткого наблюдения194 RX0.031584/TX0.002016Mbps,104 RX/TX0.002112Mbps; это не SLA/capacity/WAN proof. Пароли/конфигурация не менялись, messages0. [Production browser1440/320](ux-ui/acceptance/traffic-live-deployed-2026-10-12.json): actual live labels/receipt provenance,3APIs200,no overflow/pageerrors. Lab AUTH_REQUIRED=false; JWT/RBAC scopes не объявлены полностью принятыми по этой smoke.
Следующий180 — volume precision/binary units. Read-only baseline уже показал: measured1KiB/1MiB API round to0;100×1MiB также aggregate0;1GiB подписан1.0GB. Baseline сохранён в ignored tmp/volume180-baseline.json и пока не выдан за исправление. Sparse intervals/peer completeness/identity recreation/restore/multi-instance/scale и внешние integrations остаются12.5. Локальный коммит без push.
## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12)
Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода.
DTO серверов, пользователей и привязок объявляет volumeUnit=GiB. Frontend принимает эту единицу явно, absent unit оставляет volume — без потери known speed/history; unsupportedGB marker отклоняет DTO. fmtGiB отображает Б/КиБ/МиБ/ГиБ/ТиБ/ПиБ, positive ниже1B как<1B, true0 как0B. Число округляется только при форматировании. Existing rate formatter остаётся decimal Кбит/Мбит/Гбит, binary объёмы не смешиваются с decimal rates. TotalsRow получил semantic group label. ReUI layout/theme/components сохранены. Удалён N+1 server-status SELECT на каждую binding: используется уже прочитанный catalog snapshot.
| Состояние | Схема | Пример |
| --- | --- | --- |
| Small positive bytes | bytes → exact GiB DTO → подходящая binary unit | [ПК](ux-ui/screens/traffic-volume-1440-2026-10-12.png), [320](ux-ui/screens/traffic-volume-320-2026-10-12.png), API-fixture component capture |
| Old response without unit | unknown unit → —, speeds/history retained | E2E actual API path fixture |
Backend formulas/volume assertion (known positive delta counts; reset does not), frontend unit boundary/unknown/zero/projection, TypeScript/backend build/ESLint passed.18 E2E passed36.3s (2new volume+10detail+6mobile), screenshot delta2 passed10.5s не новые cases; final widget captures просмотрены. Новые positive fixtures явно объявляют GiB. Existing user8 GRE/VXLAN screenshots не тронуты.
Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP SSE+6new volume SQL cases, без SQL skip. Новые6 — storage/API GET для1B/1KiB/1MiB/1GiB на server/user/binding;100-part aggregate100MiB; reset lower-bound1MiB+512B. Seeded marker5 rows — собственные SQL fixtures, не физические device counts; отсутствие RouterOS calls в volume-only case не объявлено native acceptance. OwnDB mm_accept_166_1791816469068/ограниченная role/container удалены, absence verified. [SQL](ux-ui/acceptance/traffic-volume-sql-2026-10-12.json).
[Source452/522](ux-ui/acceptance/traffic-volume-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-volume-compiled-2026-10-12.json) SHA256 parity passed. Обе production builds прошли. Frontend ux-volume180 staged with backend ux-live179 [interop](ux-ui/acceptance/traffic-volume-interop-2026-10-12.json): volumes без unit unknown до backend update. Затем both ux-volume180/version1.0.0-test.volume180 [runtime](ux-ui/acceptance/traffic-volume-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-live179; no secret env copy/new migration. Старую вкладку следует перезагрузить для новых unit labels.
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
+22 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-12: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3375,3 +3375,24 @@ Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/sour
[Настоящие CHR SSE](ux-ui/acceptance/traffic-live-native-2026-10-12.json) получили первый полный sample на обоих sourceId1/2, затем streams отменены: native interface GET + monitor-traffic read-only POST. Пример краткого наблюдения194 RX0.031584/TX0.002016Mbps,104 RX/TX0.002112Mbps; это не SLA/capacity/WAN proof. Пароли/конфигурация не менялись, messages0. [Production browser1440/320](ux-ui/acceptance/traffic-live-deployed-2026-10-12.json): actual live labels/receipt provenance,3APIs200,no overflow/pageerrors. Lab AUTH_REQUIRED=false; JWT/RBAC scopes не объявлены полностью принятыми по этой smoke.
Следующий180 — volume precision/binary units. Read-only baseline уже показал: measured1KiB/1MiB API round to0;100×1MiB также aggregate0;1GiB подписан1.0GB. Baseline сохранён в ignored tmp/volume180-baseline.json и пока не выдан за исправление. Sparse intervals/peer completeness/identity recreation/restore/multi-instance/scale и внешние integrations остаются12.5. Локальный коммит без push.
## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12)
Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода.
DTO серверов, пользователей и привязок объявляет volumeUnit=GiB. Frontend принимает эту единицу явно, absent unit оставляет volume — без потери known speed/history; unsupportedGB marker отклоняет DTO. fmtGiB отображает Б/КиБ/МиБ/ГиБ/ТиБ/ПиБ, positive ниже1B как<1B, true0 как0B. Число округляется только при форматировании. Existing rate formatter остаётся decimal Кбит/Мбит/Гбит, binary объёмы не смешиваются с decimal rates. TotalsRow получил semantic group label. ReUI layout/theme/components сохранены. Удалён N+1 server-status SELECT на каждую binding: используется уже прочитанный catalog snapshot.
| Состояние | Схема | Пример |
| --- | --- | --- |
| Small positive bytes | bytes → exact GiB DTO → подходящая binary unit | [ПК](ux-ui/screens/traffic-volume-1440-2026-10-12.png), [320](ux-ui/screens/traffic-volume-320-2026-10-12.png), API-fixture component capture |
| Old response without unit | unknown unit → —, speeds/history retained | E2E actual API path fixture |
Backend formulas/volume assertion (known positive delta counts; reset does not), frontend unit boundary/unknown/zero/projection, TypeScript/backend build/ESLint passed.18 E2E passed36.3s (2new volume+10detail+6mobile), screenshot delta2 passed10.5s не новые cases; final widget captures просмотрены. Новые positive fixtures явно объявляют GiB. Existing user8 GRE/VXLAN screenshots не тронуты.
Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP SSE+6new volume SQL cases, без SQL skip. Новые6 — storage/API GET для1B/1KiB/1MiB/1GiB на server/user/binding;100-part aggregate100MiB; reset lower-bound1MiB+512B. Seeded marker5 rows — собственные SQL fixtures, не физические device counts; отсутствие RouterOS calls в volume-only case не объявлено native acceptance. OwnDB mm_accept_166_1791816469068/ограниченная role/container удалены, absence verified. [SQL](ux-ui/acceptance/traffic-volume-sql-2026-10-12.json).
[Source452/522](ux-ui/acceptance/traffic-volume-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-volume-compiled-2026-10-12.json) SHA256 parity passed. Обе production builds прошли. Frontend ux-volume180 staged with backend ux-live179 [interop](ux-ui/acceptance/traffic-volume-interop-2026-10-12.json): volumes без unit unknown до backend update. Затем both ux-volume180/version1.0.0-test.volume180 [runtime](ux-ui/acceptance/traffic-volume-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-live179; no secret env copy/new migration. Старую вкладку следует перезагрузить для новых unit labels.
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
+23 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10
Сервер161.104.46.222: завершён12.5.179 — live complete payload/expected interfaces/auth/disable-config guard; scoped retry/retain cause/latest sample, abort cleanup/receipt timestamp, positive tiny rates. OwnSQL9+14+10/24570ms,7E2E passed13.3s,types/build/ESLint/1036compiled parity. Both ux-live179 healthy/running,2 actual CHR SSE samples+live UI2 passed; ownDB/role/container removed. Следующий180 — точность и binary units учтённых объёмов: baseline positive1KiB/1MiB rounded0 и1GiB mislabeledGB подтверждён, ещё не исправлен.12.5 открыта.
Сервер161.104.46.222: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта.
| Параметр | Проверенное состояние |
| --- | --- |
@@ -14,7 +14,7 @@
| PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска |
| Образы приложения | mmapp-test/backend:ux-live179 и mmapp-test/frontend:ux-live179; запущены |
| Образы приложения | mmapp-test/backend:ux-volume180 и mmapp-test/frontend:ux-volume180; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -1801,3 +1801,24 @@ Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/sour
[Настоящие CHR SSE](ux-ui/acceptance/traffic-live-native-2026-10-12.json) получили первый полный sample на обоих sourceId1/2, затем streams отменены: native interface GET + monitor-traffic read-only POST. Пример краткого наблюдения194 RX0.031584/TX0.002016Mbps,104 RX/TX0.002112Mbps; это не SLA/capacity/WAN proof. Пароли/конфигурация не менялись, messages0. [Production browser1440/320](ux-ui/acceptance/traffic-live-deployed-2026-10-12.json): actual live labels/receipt provenance,3APIs200,no overflow/pageerrors. Lab AUTH_REQUIRED=false; JWT/RBAC scopes не объявлены полностью принятыми по этой smoke.
Следующий180 — volume precision/binary units. Read-only baseline уже показал: measured1KiB/1MiB API round to0;100×1MiB также aggregate0;1GiB подписан1.0GB. Baseline сохранён в ignored tmp/volume180-baseline.json и пока не выдан за исправление. Sparse intervals/peer completeness/identity recreation/restore/multi-instance/scale и внешние integrations остаются12.5. Локальный коммит без push.
## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12)
Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода.
DTO серверов, пользователей и привязок объявляет volumeUnit=GiB. Frontend принимает эту единицу явно, absent unit оставляет volume — без потери known speed/history; unsupportedGB marker отклоняет DTO. fmtGiB отображает Б/КиБ/МиБ/ГиБ/ТиБ/ПиБ, positive ниже1B как<1B, true0 как0B. Число округляется только при форматировании. Existing rate formatter остаётся decimal Кбит/Мбит/Гбит, binary объёмы не смешиваются с decimal rates. TotalsRow получил semantic group label. ReUI layout/theme/components сохранены. Удалён N+1 server-status SELECT на каждую binding: используется уже прочитанный catalog snapshot.
| Состояние | Схема | Пример |
| --- | --- | --- |
| Small positive bytes | bytes → exact GiB DTO → подходящая binary unit | [ПК](ux-ui/screens/traffic-volume-1440-2026-10-12.png), [320](ux-ui/screens/traffic-volume-320-2026-10-12.png), API-fixture component capture |
| Old response without unit | unknown unit → —, speeds/history retained | E2E actual API path fixture |
Backend formulas/volume assertion (known positive delta counts; reset does not), frontend unit boundary/unknown/zero/projection, TypeScript/backend build/ESLint passed.18 E2E passed36.3s (2new volume+10detail+6mobile), screenshot delta2 passed10.5s не новые cases; final widget captures просмотрены. Новые positive fixtures явно объявляют GiB. Existing user8 GRE/VXLAN screenshots не тронуты.
Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP SSE+6new volume SQL cases, без SQL skip. Новые6 — storage/API GET для1B/1KiB/1MiB/1GiB на server/user/binding;100-part aggregate100MiB; reset lower-bound1MiB+512B. Seeded marker5 rows — собственные SQL fixtures, не физические device counts; отсутствие RouterOS calls в volume-only case не объявлено native acceptance. OwnDB mm_accept_166_1791816469068/ограниченная role/container удалены, absence verified. [SQL](ux-ui/acceptance/traffic-volume-sql-2026-10-12.json).
[Source452/522](ux-ui/acceptance/traffic-volume-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-volume-compiled-2026-10-12.json) SHA256 parity passed. Обе production builds прошли. Frontend ux-volume180 staged with backend ux-live179 [interop](ux-ui/acceptance/traffic-volume-interop-2026-10-12.json): volumes без unit unknown до backend update. Затем both ux-volume180/version1.0.0-test.volume180 [runtime](ux-ui/acceptance/traffic-volume-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-live179; no secret env copy/new migration. Старую вкладку следует перезагрузить для новых unit labels.
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
@@ -0,0 +1,26 @@
{
"date": "2026-10-12",
"block": "12.5.180",
"version": "1.0.0-test.volume180",
"passed": true,
"method": "Read-only actual application volume API; no new native RouterOS commands; exact edge values covered separately by owned SQL",
"cases": [
{
"sourceId": "1",
"volumeUnit": "GiB",
"rxTotalGiB": 0.013485109433531761,
"txTotalGiB": 0.001898050308227539,
"currentState": "current"
},
{
"sourceId": "2",
"volumeUnit": "GiB",
"rxTotalGiB": 0.0021370751783251762,
"txTotalGiB": 0.0023917602375149727,
"currentState": "current"
}
],
"newRouterOSCommands": 0,
"passwordChanges": 0,
"messages": 0
}
@@ -0,0 +1,39 @@
{
"date": "2026-10-12",
"method": "Read-only current source reproduction: positive measured deltas are rounded before aggregation; binary quantity carries decimal label",
"cases": [
{
"bytes": 1024,
"actualGiB": 9.5367431640625e-7,
"apiTotal": 0,
"display": "0.0 ГБ",
"observed": true
},
{
"bytes": 1048576,
"actualGiB": 0.0009765625,
"apiTotal": 0,
"display": "0.0 ГБ",
"observed": true
},
{
"bytes": 1073741824,
"actualGiB": 1,
"apiTotal": 1,
"display": "1.0 ГБ",
"observed": true
},
{
"bytes": 1000000000,
"actualGiB": 0.9313225746154785,
"apiTotal": 0.9,
"display": "0.9 ГБ",
"observed": true
}
],
"merged100MiB": {
"apiTotal": 0,
"actualGiB": 0.09765625
},
"fixed": false
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
{
"date": "2026-10-12",
"block": "12.5.180",
"runtime": {
"backend": "ux-volume180",
"frontend": "ux-volume180"
},
"apis": [
{
"path": "/api/traffic/servers",
"status": 200
},
{
"path": "/api/traffic/users",
"status": 200
},
{
"path": "/api/traffic/bound-interfaces",
"status": 200
}
],
"cases": [
{
"width": 1440,
"trafficServers200": true,
"attemptProvenanceVisible": true,
"actualLiveSample": true,
"backendReceiptLabel": true,
"pageErrors": 0,
"noOverflow": true
},
{
"width": 320,
"trafficServers200": true,
"attemptProvenanceVisible": true,
"actualLiveSample": true,
"backendReceiptLabel": true,
"pageErrors": 0,
"noOverflow": true
}
],
"bounds": [
"Read-only smoke; error/stale/legacy branches proven by owned SQL and UI fixtures",
"No manual production scheduler/config/password/message writes"
]
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-12",
"block": "12.5.180",
"newFrontend": "ux-volume180",
"oldBackend": "ux-live179",
"readerAccepted": true,
"serverCount": 2,
"pageErrors": 0,
"bound": "Additive observed DTO reader compatibility; legacy numeric-only paths verified separately as unknown"
}
@@ -0,0 +1,25 @@
{
"date": "2026-10-12",
"block": "12.5.180",
"secretEnvCopied": false,
"rollbackTags": {
"MMAPP_IMAGE_TAG": "ux-live179",
"BACKEND_IMAGE_TAG": "ux-live179"
},
"services": {
"backend": {
"image": "mmapp-test/backend:ux-volume180",
"imageId": "sha256:225a7a5613eb3cdc15f1b7d4e8f54c4c0dc6c4395c74769eaed9ca9296a6cca1",
"state": "running",
"health": "healthy",
"matchedVerifiedImage": true
},
"frontend": {
"image": "mmapp-test/frontend:ux-volume180",
"imageId": "sha256:93b132c1ab859c5511275e14e95a74325909002cf28ae6cb053d3e362f0ae391",
"state": "running",
"health": null,
"matchedVerifiedImage": true
}
}
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-12",
"block": "12.5.180",
"matched": {
"backend": 452,
"frontend": 522
},
"different": [],
"missing": []
}
@@ -0,0 +1,30 @@
{
"date": "2026-10-12",
"block": "12.5.180",
"passed": true,
"profiles": [
{
"date": "2026-10-12",
"block": "12.5.180",
"database": "mm_accept_166_1791816469068",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:traffic-rate",
"passed": true,
"durationMs": 31474,
"ownedTrafficSqlCases": 9,
"ownedHistorySqlCases": 14,
"ownedLiveSqlCases": 10,
"ownedVolumeSqlCases": 6,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+6 -1
View File
@@ -1,6 +1,11 @@
import assert from "node:assert/strict"
import { fmtRate } from "./fmt-rate.js"
import { fmtRate, fmtGiB } from "./fmt-rate.js"
assert.equal(fmtRate(null),"—");assert.equal(fmtRate(undefined),"—");assert.equal(fmtRate(0),"0 Кбит/с")
assert.equal(fmtRate(0.000001),"<1 Кбит/с");assert.equal(fmtRate(0.001),"1 Кбит/с")
assert.equal(fmtRate(1),"1.00 Мбит/с");assert.equal(fmtRate(1000),"1.00 Гбит/с")
console.log("rate formatting unknown/zero/positive-small/units tests ok")
assert.equal(fmtGiB(null),"—");assert.equal(fmtGiB(0),"0 Б")
assert.equal(fmtGiB(1/1024**3),"1 Б");assert.equal(fmtGiB(1/1024**2),"1 КиБ")
assert.equal(fmtGiB(1/1024),"1 МиБ");assert.equal(fmtGiB(1),"1 ГиБ");assert.equal(fmtGiB(1024),"1 ТиБ")
assert.equal(fmtGiB(100/1024),"100 МиБ");assert.equal(fmtGiB(Number.MAX_VALUE),"—")
console.log("binary traffic volume precision/unit boundary tests ok")
+11 -4
View File
@@ -10,10 +10,17 @@ export function fmtRate(v: number | null | undefined): string {
return `${Math.round(v)} Мбит/с`
}
export function fmtGB(v: number | null | undefined): string {
if (v == null) return "—"
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
return `${v.toFixed(1)} ГБ`
const binaryFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 })
/** Traffic API volumeUnit=GiB: preserve binary units and never format positive bytes as zero. */
export function fmtGiB(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v) || v < 0) return "—"
const bytes = v * 1024 ** 3
if (!Number.isFinite(bytes)) return "—"
if (bytes === 0) return "0 Б"
if (bytes < 1) return "<1 Б"
const units = ["Б", "КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ"]
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
return `${binaryFormatter.format(bytes / 1024 ** index)} ${units[index]}`
}
export function formatBytes(n: number): string {
+3
View File
@@ -5,6 +5,9 @@ const row={rxNow:0,txNow:0,rxPeak:0,txPeak:0,rxTotal:0,txTotal:0,rxSeries:[0,0],
const obs={rxNow:true,txNow:true,rxSeries:[false,true],txSeries:[false,true],sampledAt,currentState:"current",expiresAt}
assert.equal(projectTrafficObservations(row,at).rxNow,null)
const known=projectTrafficObservations({...row,observations:obs},at);assert.equal(known.rxNow,0);assert.deepEqual(known.rxSeries,[null,0])
assert.equal(known.rxTotal,null)
assert.equal(projectTrafficObservations({...row,volumeUnit:"GiB",observations:obs},at).rxTotal,0)
assert.throws(()=>projectTrafficObservations({...row,volumeUnit:"GB",observations:obs},at))
const stale=projectTrafficObservations({...row,observations:obs},at+90_001);assert.equal(stale.rxNow,null);assert.equal(stale.observations?.currentState,"stale");assert.deepEqual(stale.rxSeries,[null,0])
assert.equal(projectTrafficObservations({...row,observations:{...obs,currentState:"error"}},at).rxNow,null)
for(const change of [{rxNow:-1},{observations:{...obs,sampledAt:"bad"}},{observations:{...obs,sampledAt:null}},{observations:{...obs,rxSeries:[]}}])assert.throws(()=>projectTrafficObservations({...row,...change},at))
+3 -3
View File
@@ -5,7 +5,7 @@ const meta = z.object({ rxNow: z.boolean(), txNow: z.boolean(), rxSeries: z.arra
sampledAt: timestamp, expiresAt: timestamp.optional(), lastAttemptAt: timestamp.optional(), lastSucceededAt: timestamp.optional(), error: z.string().nullable().optional(),
currentState: z.enum(["current", "unknown", "pending", "error", "stale", "disabled"]).optional() })
export type TrafficObservationMeta = z.infer<typeof meta>
const metrics = z.object({ rxNow: rate, txNow: rate, rxPeak: rate, txPeak: rate, rxTotal: rate, txTotal: rate,
const metrics = z.object({ volumeUnit: z.literal("GiB").optional(), rxNow: rate, txNow: rate, rxPeak: rate, txPeak: rate, rxTotal: rate, txTotal: rate,
rxSeries: z.array(rate), txSeries: z.array(rate), observations: meta.optional() }).refine(r =>
(!r.observations || r.observations.rxSeries.length === r.rxSeries.length && r.observations.txSeries.length === r.txSeries.length &&
(!(r.observations.rxNow || r.observations.txNow || r.observations.rxSeries.some(Boolean) || r.observations.txSeries.some(Boolean)) || r.observations.sampledAt !== null)))
@@ -18,8 +18,8 @@ export function projectTrafficObservations(raw: unknown, now = Date.now()) {
const current = !o?.currentState || o.currentState === "current"
const rxSeries = r.rxSeries.map((v, i) => o?.rxSeries[i] ? v : null)
const txSeries = r.txSeries.map((v, i) => o?.txSeries[i] ? v : null)
return { observations: o, rxNow: current && o?.rxNow ? r.rxNow : null, txNow: current && o?.txNow ? r.txNow : null,
return { observations: o, volumeUnit: r.volumeUnit, rxNow: current && o?.rxNow ? r.rxNow : null, txNow: current && o?.txNow ? r.txNow : null,
rxSeries, txSeries, rxPeak: rxSeries.some(v => v !== null) ? r.rxPeak : null,
txPeak: txSeries.some(v => v !== null) ? r.txPeak : null,
rxTotal: rxSeries.some(v => v !== null) ? r.rxTotal : null, txTotal: txSeries.some(v => v !== null) ? r.txTotal : null }
rxTotal: r.volumeUnit && rxSeries.some(v => v !== null) ? r.rxTotal : null, txTotal: r.volumeUnit && txSeries.some(v => v !== null) ? r.txTotal : null }
}
+1
View File
@@ -4,6 +4,7 @@ export function observedTrafficFixturePayload(value: unknown): unknown {
if (!value || typeof value !== "object") return value
const r = Object.fromEntries(Object.entries(value).map(([key, v]) => [key, observedTrafficFixturePayload(v)]))
if ("rxNow" in r && "txNow" in r && "rxTotal" in r && "txTotal" in r && Array.isArray(r.rxSeries) && Array.isArray(r.txSeries) && !("observations" in r)) {
r.volumeUnit = "GiB"
r.observations = { rxNow: true, txNow: true, sampledAt: "2026-10-12T12:00:00Z",
rxSeries: r.rxSeries.map(v => typeof v === "number" && Number.isFinite(v) && v >= 0),
txSeries: r.txSeries.map(v => typeof v === "number" && Number.isFinite(v) && v >= 0) }
+19
View File
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test"
for(const width of[1440,320])test(`Объём трафика: положительные байты/бинарные единицы/неизвестная единица ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000});let unit=true,rxTotal=1/1024,txTotal=1/1024**3
const server=()=>({id:"1",name:"Объём CHR",site:"TEST",country:"RU",status:"online",rxNow:0,txNow:0,rxPeak:0,txPeak:0,rxTotal,txTotal,rxSeries:[0,0],txSeries:[0,0],sessions:1,...(unit?{volumeUnit:"GiB"}:{}),observations:{rxNow:true,txNow:true,rxSeries:[false,true],txSeries:[false,true],sampledAt:"2026-10-12T12:00:00Z"}})
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{
const path=new URL(r.request().url()).pathname;if(path.endsWith("/live"))return r.fulfill({status:503,json:{error:"Fixture без live"}})
return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/traffic/servers"?{servers:[server()]}:path==="/api/traffic/servers/1"?{server:server()}:path.endsWith("/interfaces")||path==="/api/traffic/bound-interfaces"?{interfaces:[]}:path==="/api/traffic/users"?{users:[]}:path==="/api/servers"?[]:{}})
});await page.goto("/traffic")
const volume=page.getByRole("group",{name:"Учтённый объём по измерениям"})
await expect(volume).toContainText("1 МиБ");await expect(volume).toContainText("1 Б")
await volume.screenshot({path:`docs/ux-ui/screens/traffic-volume-${width}-2026-10-12.png`})
rxTotal=1;txTotal=1024;await page.getByRole("button",{name:"15м",exact:true}).click()
await expect(volume).toContainText("1 ГиБ");await expect(volume).toContainText("1 ТиБ")
unit=false;await page.getByRole("button",{name:"24ч",exact:true}).click()
await expect(volume.getByText("—",{exact:true})).toHaveCount(2)
unit=true;rxTotal=0;txTotal=0;await page.getByRole("button",{name:"4ч",exact:true}).click()
await expect(volume.getByText("0 Б",{exact:true})).toHaveCount(2)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true)
})