fix(traffic): проверять live-срез и прекращать опрос выключенных устройств

This commit is contained in:
Denozordec committed 2026-10-12 21:35:55 +07:00
1 parent 1275dd7b9b
commit 2358ecef60
29 files changed
+1620 -114

No files matched your search

+13 -3
View File
@@ -793,6 +793,7 @@ export default function TrafficPage() {
const [range, setRange] = useState<Range>("1h") const [range, setRange] = useState<Range>("1h")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [trafficRetry, setTrafficRetry] = useState(0) const [trafficRetry, setTrafficRetry] = useState(0)
const [liveRetry, setLiveRetry] = useState(0)
const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry]) const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry])
const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; receivedAt?: number; error?: string } | null>(null) const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; receivedAt?: number; error?: string } | null>(null)
const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready" const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready"
@@ -843,6 +844,7 @@ export default function TrafficPage() {
backendUrl, backendUrl,
serverId: selectedId, serverId: selectedId,
iface: selectedIface, iface: selectedIface,
reloadKey: liveRetry,
}) })
const confirmedFlowSelection = isLive && Boolean(currentFlows) && !liveError && ( const confirmedFlowSelection = isLive && Boolean(currentFlows) && !liveError && (
range === "30d" range === "30d"
@@ -1472,7 +1474,14 @@ export default function TrafficPage() {
</>} </>}
{isLive && effectiveMode === "servers" && detailServer && <> {isLive && effectiveMode === "servers" && detailServer && <>
<TrafficDataTime label="Получение истории сервера" receivedAt={currentDetail?.receivedAt} source="API" /> <TrafficDataTime label="Получение истории сервера" receivedAt={currentDetail?.receivedAt} source="API" />
<TrafficDataTime label="Последнее измерение сервера" receivedAt={liveReceivedAt} source="live-поток" serverAt={liveSample?.at} /> <TrafficDataTime label="Последнее измерение сервера" receivedAt={liveReceivedAt} source="live-поток" serverAt={liveSample?.at}
serverTimeLabel={liveSample?.timestampKind === "backend-receipt" ? "ответ RouterOS получен приложением" : undefined} />
{liveStreamError && <Alert variant={liveStreamError === "Ожидание нового live-измерения" ? "default" : "warning"} role="status" className="mb-3">
<AlertTitle>{liveStreamError === "Ожидание нового live-измерения" ? "Подключение live-потока" : "Live-поток: нет свежих данных"}</AlertTitle>
<AlertDescription className="break-words">{liveStreamError}
<Button size="sm" variant="outline" disabled={liveStreamError === "Ожидание нового live-измерения"} onClick={() => setLiveRetry(value => value + 1)}>Переподключить live</Button>
</AlertDescription>
</Alert>}
</>} </>}
{effectiveMode === "servers" && detailServer && ( {effectiveMode === "servers" && detailServer && (
<ServerDetail <ServerDetail
@@ -1481,8 +1490,9 @@ export default function TrafficPage() {
setRange={setRange} setRange={setRange}
liveRx={liveSample?.rxMbps} liveRx={liveSample?.rxMbps}
liveTx={liveSample?.txMbps} liveTx={liveSample?.txMbps}
liveHint={liveStreamError liveHint={liveStreamError === "Ожидание нового live-измерения"
? (liveSample ? "Последнее live-измерение · поток недоступен" : "История · live недоступен") ? (liveSample ? "Последнее live" : "История · ожидание live") : liveStreamError
? (liveSample ? "Последнее live" : "История · live недоступен")
: liveSample ? "live" : isLive ? "История · ожидание live" : undefined} : liveSample ? "live" : isLive ? "История · ожидание live" : undefined}
/> />
)} )}
+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: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: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: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-observation-sql.test.ts && tsx src/services/traffic-history-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",
"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: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: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", "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",
+9 -68
View File
@@ -12,7 +12,8 @@ import {
} from "../services/traffic-collector.js" } from "../services/traffic-collector.js"
import { refreshScheduler, runSchedulerJobNow } from "../services/scheduler.js" import { refreshScheduler, runSchedulerJobNow } from "../services/scheduler.js"
import { appendEvent } from "../modules/events/service/events-service.js" import { appendEvent } from "../modules/events/service/events-service.js"
import { MikrotikClient } from "../services/mikrotik.js" import { readTrafficLiveSample, TrafficLiveSourceChanged } from "../services/traffic-live.js"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js" import { getEnabledServerById } from "../services/wireguard-live.js"
import { readTrafficCollectionContext } from "../services/traffic-collection-state.js" import { readTrafficCollectionContext } from "../services/traffic-collection-state.js"
import { applyTrafficFreshness } from "../services/traffic-freshness.js" import { applyTrafficFreshness } from "../services/traffic-freshness.js"
@@ -20,7 +21,6 @@ import {
buildTrafficFromSamples, buildTrafficFromSamples,
type TrafficObservations, type TrafficObservations,
isLoopbackName, isLoopbackName,
parseMonitorTraffic,
} from "../services/traffic-rate.js" } from "../services/traffic-rate.js"
import { import {
buildBoundInterfaceTraffic, buildBoundInterfaceTraffic,
@@ -54,7 +54,6 @@ interface TrafficInterfaceDto {
} }
const LIVE_TICK_MS = 1500 const LIVE_TICK_MS = 1500
const LIVE_ROS_TIMEOUT_MS = 4000
async function latestSnapshot(serverId: number) { async function latestSnapshot(serverId: number) {
return (await db return (await db
@@ -141,68 +140,6 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
}) })
} }
function flattenMonitor(raw: unknown): unknown[] {
if (Array.isArray(raw)) return raw
if (raw != null) return [raw]
return []
}
async function listRunningIfaceNames(client: MikrotikClient): Promise<string[]> {
const ifaces = await client.get<Array<{ name?: string; running?: string; disabled?: string }>>(
"/interface",
LIVE_ROS_TIMEOUT_MS,
)
return ifaces
.filter((i) => (i.running ?? "false") === "true"
&& (i.disabled ?? "false") !== "true"
&& !isLoopbackName(i.name ?? ""))
.map((i) => i.name ?? "")
.filter(Boolean)
}
async function monitorTrafficOnce(
client: MikrotikClient,
onlyInterface: string | undefined,
cache: { names: string[]; joinedFailed: boolean },
signal: AbortSignal,
): Promise<unknown> {
if (onlyInterface) {
return client.post(
"/interface/monitor-traffic",
{ interface: onlyInterface, once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
)
}
if (cache.names.length === 0) {
cache.names = await listRunningIfaceNames(client)
}
if (cache.names.length === 0) return []
if (!cache.joinedFailed) {
try {
return await client.post(
"/interface/monitor-traffic",
{ interface: cache.names.join(","), once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
)
} catch {
cache.joinedFailed = true
}
}
const chunks = await Promise.all(
cache.names.map((name) =>
client.post(
"/interface/monitor-traffic",
{ interface: name, once: "" },
LIVE_ROS_TIMEOUT_MS,
signal,
).then(flattenMonitor).catch(() => [] as unknown[]),
),
)
return chunks.flat()
}
const trafficRoutes: FastifyPluginAsyncZod = async (app) => { const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/traffic/settings", async (_req, reply) => { app.get("/traffic/settings", async (_req, reply) => {
const settings = await getTrafficSettings() const settings = await getTrafficSettings()
@@ -344,13 +281,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
try { try {
while (!abort.signal.aborted) { while (!abort.signal.aborted) {
try { try {
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal) const sample = await readTrafficLiveSample(client, onlyInterface, cache, abort.signal, async () => {
const sample = parseMonitorTraffic(raw, { onlyInterface }) const current = await getEnabledServerById(server.id)
if (!current) throw new TrafficLiveSourceChanged("Устройство выключено или удалено. Live-поток остановлен")
if (Date.parse(current.updatedAt) !== Date.parse(server.updatedAt)) throw new TrafficLiveSourceChanged("Конфигурация устройства изменилась. Переподключите live-поток")
})
writeSse(reply.raw, "sample", sample) writeSse(reply.raw, "sample", sample)
} catch (error) { } catch (error) {
if (abort.signal.aborted) break if (abort.signal.aborted) break
const msg = error instanceof Error ? error.message : String(error) const msg = error instanceof Error ? error.message : String(error)
writeSse(reply.raw, "error", { error: msg }) writeSse(reply.raw, "error", { error: msg.slice(0,1024) })
if (error instanceof TrafficLiveSourceChanged || error instanceof MikrotikError && [401,403].includes(error.statusCode)) break
} }
await sleep(LIVE_TICK_MS, abort.signal) await sleep(LIVE_TICK_MS, abort.signal)
} }
+6 -6
View File
@@ -157,8 +157,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
sampledAt: now, sampledAt: now,
rxBytes, rxBytes,
txBytes, txBytes,
rxBps, rxBps: Math.round(rxBps),
txBps, txBps: Math.round(txBps),
flags: encodeTrafficFlags(running, disabled, true), flags: encodeTrafficFlags(running, disabled, true),
} }
}) })
@@ -186,8 +186,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
sampledAt: now, sampledAt: now,
rxBytes, rxBytes,
txBytes, txBytes,
rxBps, rxBps: Math.round(rxBps),
txBps, txBps: Math.round(txBps),
flags: encodeTrafficFlags(running, disabled, true), flags: encodeTrafficFlags(running, disabled, true),
}) })
} }
@@ -205,8 +205,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
ok: true, ok: true,
interfaces: ifaces.length, interfaces: ifaces.length,
...(includedInterfaces > 0 && totalsMeasured ? { ...(includedInterfaces > 0 && totalsMeasured ? {
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000, sumRxMbps,
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000, sumTxMbps,
} : {}), } : {}),
}) })
} catch (err) { } catch (err) {
@@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
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 live 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-live179-"+Date.now()+"-"
let serverId:number|undefined,mode="zero",nativeRequests=0,cases=0,two=false
const point=(name:string)=>({name,"rx-bits-per-second":"0","tx-bits-per-second":"0"})
const http=createServer(async(req,res)=>{
nativeRequests++;res.setHeader("Content-Type","application/json")
if(req.method==="GET")return res.end(JSON.stringify((two?["ether1","ether2"]:["ether1"]).map(name=>({name,running:"true",disabled:"false"}))))
let data="";for await(const chunk of req)data+=chunk;const name=JSON.parse(data).interface
if(mode==="auth"||mode==="fallback-deny"&&name==="ether2"){res.statusCode=401;return res.end('{"message":"Unauthorized"}')}
if(mode.startsWith("fallback")&&name.includes(",")){res.statusCode=400;return res.end('{"message":"Unsupported joined interface"}')}
if(mode==="empty")return res.end("[]")
if(mode==="malformed")return res.end(JSON.stringify([{name,"rx-bits-per-second":"12bad","tx-bits-per-second":"0"}]))
if(mode==="partial")return res.end(JSON.stringify([point("ether1")]))
res.end(JSON.stringify(name.split(",").map(point)))
})
await new Promise<void>(resolve=>http.listen(0,"127.0.0.1",resolve));const address=http.address();assert(address&&typeof address==="object")
const app=Fastify();await app.register(trafficRoutes)
const streams=new Set<AbortController>()
async function open() {
const ac=new AbortController();streams.add(ac);const r=await fetch(app.listeningOrigin+`/traffic/servers/${serverId}/live`,{signal:ac.signal});assert.equal(r.status,200);assert(r.body)
const reader=r.body.getReader(),decoder=new TextDecoder();let buffer=""
return {ac, async next(){const timeout=setTimeout(()=>ac.abort(),8000);try{
for(;;){const end=buffer.indexOf("\n\n");if(end>=0){const block=buffer.slice(0,end);buffer=buffer.slice(end+2);const event=block.match(/^event: (.*)$/m)?.[1],data=block.match(/^data: (.*)$/m)?.[1];if(event&&data)return{event,data:JSON.parse(data)};continue}
const chunk=await reader.read();if(chunk.done)return null;buffer+=decoder.decode(chunk.value,{stream:true})}
}finally{clearTimeout(timeout)}},close(){ac.abort();streams.delete(ac)}}
}
try {
await initDatabase();await app.listen({host:"127.0.0.1",port:0})
serverId=Number((await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,updated_at) VALUES($1,'127.0.0.1',$2,false,true,'fixture','fixture',$3) RETURNING id",[prefix+"server",address.port,new Date(Date.now()-3_600_000).toISOString()])).rows[0].id)
let stream=await open(),event=await stream.next();assert.equal(event?.event,"sample");assert.equal(event?.data.rxMbps,0);assert.equal(event?.data.timestampKind,"backend-receipt");stream.close();cases++
for(const kind of ["empty","malformed","partial"]){mode=kind;two=kind==="partial";stream=await open();event=await stream.next();assert.equal(event?.event,"error");stream.close();cases++}
mode="fallback";two=true;stream=await open();event=await stream.next();assert.equal(event?.event,"sample");assert.equal(event?.data.rxMbps,0);stream.close();cases++
mode="fallback-deny";stream=await open();event=await stream.next();assert.equal(event?.event,"error");assert.match(event?.data.error,/401/);assert.equal(await stream.next(),null);stream.close();cases++
mode="auth";two=false;stream=await open();event=await stream.next();assert.equal(event?.event,"error");assert.equal(await stream.next(),null);stream.close();cases++
mode="zero";stream=await open();await stream.next();const beforeDisable=nativeRequests
await pool.query("UPDATE servers SET enabled=false WHERE id=$1",[serverId]);event=await stream.next();assert.equal(event?.event,"error");assert.match(event?.data.error,/выключено/);assert.equal(await stream.next(),null);assert.equal(nativeRequests,beforeDisable);stream.close();cases++
const beforeInitial=nativeRequests;const disabled=await app.inject({method:"GET",url:`/traffic/servers/${serverId}/live`});assert.equal(disabled.statusCode,404);assert.equal(nativeRequests,beforeInitial);cases++
await pool.query("UPDATE servers SET enabled=true WHERE id=$1",[serverId]);stream=await open();await stream.next();const beforeConfig=nativeRequests
await pool.query("UPDATE servers SET updated_at=$1 WHERE id=$2",[new Date(Date.now()+1000).toISOString(),serverId]);event=await stream.next();assert.match(event?.data.error,/Конфигурация/);assert.equal(await stream.next(),null);assert.equal(nativeRequests,beforeConfig);stream.close();cases++
console.log(`traffic live owned SQL: ${cases} cases; real TCP SSE/HTTP/zero/empty/malformed/partial/fallback/auth/disabled/config passed`)
}finally{
for(const ac of streams)ac.abort();await app.close();await new Promise<void>(resolve=>http.close(()=>resolve()))
if(serverId!==undefined)await pool.query("DELETE FROM servers WHERE id=$1",[serverId]);await closePool()
}
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { parseMonitorTraffic, rateBpsFromDelta, bpsToMbps } from "./traffic-rate.js"
import { readTrafficLiveSample, TrafficLiveSourceChanged } from "./traffic-live.js"
import { MikrotikError, type MikrotikClient } from "./mikrotik.js"
const point=(name="ether1",rx:unknown="0",tx:unknown="0")=>({name,"rx-bits-per-second":rx,"tx-bits-per-second":tx})
for(const raw of [[],{},null,[null],[{...point(),"rx-bits-per-second":undefined}], [point("ether1","12bad")],[point("ether1",-1)], [point("ether1"),point("ether1")]]) {
assert.throws(()=>parseMonitorTraffic(raw))
}
assert.throws(()=>parseMonitorTraffic([point()],{expectedInterfaces:["ether1","ether2"]}))
assert.throws(()=>parseMonitorTraffic([point("other")],{onlyInterface:"ether1"}))
assert.equal(parseMonitorTraffic([point()]).rxMbps,0)
assert.equal(parseMonitorTraffic([point("ether1","1")]).rxMbps,0.000001)
assert(rateBpsFromDelta(1000,1001,0,30_000)!>0);assert(bpsToMbps(rateBpsFromDelta(1000,1001,0,30_000)!)>0)
assert.equal(parseMonitorTraffic([point()]).timestampKind,"backend-receipt")
let mode="fallback",postNames:string[]=[],guards=0
const client={get:async()=>[{name:"ether1",running:"true",disabled:"false"},{name:"ether2",running:true,disabled:false}],
post:async(_path:string,body:{interface:string})=>{postNames.push(body.interface);if(body.interface.includes(",")){
if(mode==="auth")throw new MikrotikError(401,"/interface/monitor-traffic","Unauthorized")
if(mode==="partial")return[point()]
throw new MikrotikError(400,"/interface/monitor-traffic","Unsupported joined interface")
}if(mode==="deny"&&body.interface==="ether2")throw new MikrotikError(401,"/interface/monitor-traffic","Unauthorized");return[point(body.interface,"1")]}}
const cast=client as unknown as Pick<MikrotikClient,"get"|"post">
const run=()=>readTrafficLiveSample(cast,undefined,{names:[],joinedFailed:false},new AbortController().signal,async()=>{guards++})
const complete=await run();assert.equal(complete.rxMbps,0.000002);assert(guards>=4)
mode="deny";await assert.rejects(run(),/401/)
mode="auth";postNames=[];await assert.rejects(run(),/401/);assert.equal(postNames.length,1)
mode="partial";postNames=[];await assert.rejects(run(),/все live/);assert.equal(postNames.length,1)
await assert.rejects(readTrafficLiveSample(cast,undefined,{names:[],joinedFailed:false},new AbortController().signal,async()=>{throw new TrafficLiveSourceChanged("disabled")}),/disabled/)
const abort=new AbortController();abort.abort();postNames=[];await assert.rejects(readTrafficLiveSample(cast,"ether1",{names:[],joinedFailed:false},abort.signal,async()=>{}));assert.equal(postNames.length,0)
let active=0,maximum=0
const many={get:async()=>Array.from({length:10},(_,i)=>({name:`ether${i}`,running:true,disabled:false})),post:async(_path:string,body:{interface:string})=>{
if(body.interface.includes(","))throw new MikrotikError(400,"/interface/monitor-traffic","Joined unsupported")
active++;maximum=Math.max(maximum,active);await new Promise(resolve=>setTimeout(resolve,5));active--;return[point(body.interface)]
}} as unknown as Pick<MikrotikClient,"get"|"post">
const bounded=await readTrafficLiveSample(many,undefined,{names:[],joinedFailed:false},new AbortController().signal,async()=>{})
assert.equal(bounded.rxMbps,0);assert.equal(maximum,4)
console.log("traffic live completeness/fallback/auth/source/abort/positive-small-rate tests ok")
+73
View File
@@ -0,0 +1,73 @@
import { MikrotikError, type MikrotikClient } from "./mikrotik.js"
import { isLoopbackName, parseMonitorTraffic, type MonitorLiveSample } from "./traffic-rate.js"
const TIMEOUT_MS = 4000
export class TrafficLiveSourceChanged extends Error {}
export interface TrafficLiveCache { names: string[]; joinedFailed: boolean }
type Client = Pick<MikrotikClient, "get" | "post">
/** Never emit a partial sum, and check the source before every new native request. */
export async function readTrafficLiveSample(client: Client, onlyInterface: string | undefined,
cache: TrafficLiveCache, signal: AbortSignal, assertSource: () => Promise<void>): Promise<MonitorLiveSample> {
const parentSignal = signal, wave = new AbortController()
const onAbort = () => wave.abort(parentSignal.reason)
parentSignal.addEventListener("abort", onAbort, { once: true })
if (parentSignal.aborted) onAbort()
const timeout = setTimeout(() => wave.abort(new Error("Не успели получить полный live-срез за 8 секунд. Выберите отдельный интерфейс")), 8000)
signal = wave.signal
try {
const ensure = async () => { signal.throwIfAborted(); await assertSource(); signal.throwIfAborted() }
const monitor = async (name: string) => {
await ensure()
return client.post<unknown>("/interface/monitor-traffic", { interface: name, once: "" }, TIMEOUT_MS, signal)
}
if (onlyInterface) {
const raw = await monitor(onlyInterface)
await ensure()
return parseMonitorTraffic(raw, { onlyInterface })
}
await ensure()
const raw = await client.get<unknown>("/interface", TIMEOUT_MS)
if (!Array.isArray(raw)) throw new Error("Не получен каталог интерфейсов live")
const names: string[] = []
const flag = (value: unknown) => {
if (value === true || value === "true" || value === "yes") return true
if (value === false || value === "false" || value === "no") return false
throw new Error("Неизвестное состояние интерфейса live")
}
for (const row of raw) {
if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.name !== "string" || !row.name.trim()) throw new Error("Некорректный каталог интерфейсов live")
if (flag(row.running) && !flag(row.disabled) && !isLoopbackName(row.name)) names.push(row.name)
}
if (!names.length || new Set(names).size !== names.length) throw new Error("Нет достоверного набора активных интерфейсов live")
if ([...names].sort().join("\0") !== [...cache.names].sort().join("\0")) cache.joinedFailed = false
cache.names = names
if (!cache.joinedFailed) {
try {
const result = await monitor(names.join(","))
await ensure()
return parseMonitorTraffic(result, { expectedInterfaces: names })
} catch (error) {
// Unsupported joined command may fall back; auth/transport/invalid payload cannot become partial zero.
if (!(error instanceof MikrotikError) || error.statusCode !== 400 || names.length < 2) throw error
cache.joinedFailed = true
}
}
const results: MonitorLiveSample[] = []
for (let i = 0; i < names.length; i += 4) {
results.push(...await Promise.all(names.slice(i, i + 4).map(async name =>
parseMonitorTraffic(await monitor(name), { onlyInterface: name }))))
}
await ensure()
const rxMbps = results.reduce((sum, r) => sum + r.rxMbps, 0), txMbps = results.reduce((sum, r) => sum + r.txMbps, 0)
if (![rxMbps, txMbps].every(n => Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER / 1_000_000)) throw new Error("Суммарная live-скорость вне допустимого диапазона")
return { rxMbps, txMbps, at: new Date().toISOString(), timestampKind: "backend-receipt" }
} catch (error) {
if (signal.aborted && !parentSignal.aborted && signal.reason instanceof Error) throw signal.reason
throw error
} finally {
clearTimeout(timeout)
parentSignal.removeEventListener("abort", onAbort)
wave.abort()
}
}
+26 -10
View File
@@ -42,6 +42,7 @@ export interface MonitorLiveSample {
rxMbps: number rxMbps: number
txMbps: number txMbps: number
at: string at: string
timestampKind: "backend-receipt"
} }
export function isLoopbackName(name: string): boolean { export function isLoopbackName(name: string): boolean {
@@ -61,7 +62,7 @@ export function shouldIncludeIface(
export function bpsToMbps(bps: number): number { export function bpsToMbps(bps: number): number {
if (!Number.isFinite(bps) || bps <= 0) return 0 if (!Number.isFinite(bps) || bps <= 0) return 0
return Math.round((bps / 1_000_000) * 1000) / 1000 return bps / 1_000_000
} }
/** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */ /** bits/s из соседних счётчиков. null = нельзя (Δt≤0 или сброс). */
@@ -75,7 +76,7 @@ export function rateBpsFromDelta(
const dtSec = (nextAtMs - prevAtMs) / 1000 const dtSec = (nextAtMs - prevAtMs) / 1000
if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null if (!(dtSec > 0) || !Number.isFinite(dtSec)) return null
if (nextBytes < prevBytes) return null if (nextBytes < prevBytes) return null
const rate=Math.round(((nextBytes - prevBytes) * 8) / dtSec) const rate=((nextBytes - prevBytes) * 8) / dtSec
return Number.isFinite(rate)?rate:null return Number.isFinite(rate)?rate:null
} }
@@ -274,26 +275,41 @@ export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeri
export function parseMonitorTraffic( export function parseMonitorTraffic(
raw: unknown, raw: unknown,
opts?: { onlyInterface?: string }, opts?: { onlyInterface?: string; expectedInterfaces?: readonly string[] },
): MonitorLiveSample { ): MonitorLiveSample {
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : [] const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
let rxBps = 0 let rxBps = 0
let txBps = 0 let txBps = 0
const seen = new Set<string>()
const expected = opts?.expectedInterfaces ? new Set(opts.expectedInterfaces) : null
const rate = (raw: unknown) => {
if (typeof raw !== "number" && (typeof raw !== "string" || !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(raw.trim()))) throw new Error("Некорректная скорость monitor-traffic")
const n = Number(raw)
if (!Number.isFinite(n) || n < 0 || n > Number.MAX_SAFE_INTEGER) throw new Error("Некорректная скорость monitor-traffic")
return n
}
for (const item of items) { for (const item of items) {
if (!item || typeof item !== "object") continue if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("Некорректный ответ monitor-traffic")
const rec = item as Record<string, unknown> const rec = item as Record<string, unknown>
const name = String(rec.name ?? rec.interface ?? "") const name = rec.name ?? rec.interface
if (typeof name !== "string" || !name.trim()) throw new Error("Не указан интерфейс monitor-traffic")
if (opts?.onlyInterface) { if (opts?.onlyInterface) {
if (name && name !== opts.onlyInterface) continue if (name !== opts.onlyInterface) throw new Error("Ответ monitor-traffic относится к другому интерфейсу")
} else if (isLoopbackName(name)) { } else if (isLoopbackName(name)) {
continue continue
} }
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0 if (seen.has(name) || expected && !expected.has(name)) throw new Error("Некорректный набор интерфейсов monitor-traffic")
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0 seen.add(name)
rxBps += rate(rec["rx-bits-per-second"])
txBps += rate(rec["tx-bits-per-second"])
} }
if (!seen.size || expected && (expected.size !== opts!.expectedInterfaces!.length ||
[...expected].some(name => !seen.has(name)))) throw new Error("Не получены все live-измерения интерфейсов")
if (![rxBps, txBps].every(n => Number.isFinite(n) && n <= Number.MAX_SAFE_INTEGER)) throw new Error("Суммарная live-скорость вне допустимого диапазона")
return { return {
rxMbps: bpsToMbps(rxBps), rxMbps: rxBps / 1_000_000,
txMbps: bpsToMbps(txBps), txMbps: txBps / 1_000_000,
at: new Date().toISOString(), at: new Date().toISOString(),
timestampKind: "backend-receipt",
} }
} }
+3 -2
View File
@@ -1,9 +1,10 @@
/** Receipt time is not the timestamp of a network measurement. */ /** Receipt time is not the timestamp of a network measurement. */
export function TrafficDataTime({ label, receivedAt, source, serverAt }: { export function TrafficDataTime({ label, receivedAt, source, serverAt, serverTimeLabel = "метка сервера" }: {
label: string label: string
receivedAt?: number | null receivedAt?: number | null
source: "API" | "live-поток" source: "API" | "live-поток"
serverAt?: string serverAt?: string
serverTimeLabel?: string
}) { }) {
if (receivedAt == null || !Number.isFinite(receivedAt)) return null if (receivedAt == null || !Number.isFinite(receivedAt)) return null
const date = new Date(receivedAt) const date = new Date(receivedAt)
@@ -11,7 +12,7 @@ export function TrafficDataTime({ label, receivedAt, source, serverAt }: {
<p aria-label={label} className="text-xs text-muted-foreground mb-3 break-words"> <p aria-label={label} className="text-xs text-muted-foreground mb-3 break-words">
Получено ({source}): <time dateTime={date.toISOString()}>{date.toLocaleString("ru-RU")}</time> Получено ({source}): <time dateTime={date.toISOString()}>{date.toLocaleString("ru-RU")}</time>
{serverAt && Number.isFinite(Date.parse(serverAt)) {serverAt && Number.isFinite(Date.parse(serverAt))
? <> · метка сервера: <time dateTime={serverAt}>{new Date(serverAt).toLocaleString("ru-RU")}</time></> ? <> · {serverTimeLabel}: <time dateTime={serverAt}>{new Date(serverAt).toLocaleString("ru-RU")}</time></>
: " · время измерения не предоставлено"} : " · время измерения не предоставлено"}
</p> </p>
) )
+25 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5 # UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
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). 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) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2047,3 +2047,26 @@ Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history contr
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy. [Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push. Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и 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. Это не подтверждение максимальной производительности интерфейса.
Новый readTrafficLiveSample revalidates enabled/current config перед каждым новым native request и перед публикацией. Device disabled/deleted/config change закрывает SSE с причиной; HTTP401/403 закрывают поток без повторного опроса. Inventory обновляется каждый aggregate tick. Только joined HTTP400 при нескольких интерфейсах допускает per-interface fallback; failures не заглушаются пустыми массивами, partial sum не публикуется. Fallback≤4 concurrent commands, complete-wave budget8s; parent abort/deadline отменяет POST/ожидаемые продолжения. Существующий GET транспорт не принимает AbortSignal, его already-started read ограничен4s; после abort не запускаются следующие commands. Нельзя отозвать уже отправленную native команду. Multiple instances/large inventory/long WAN/revocation of already-established JWT streams отдельно не приняты.
Hook сохраняет конкретную причину error при end-of-stream, закрывает transport после decoding/HTTP failure и cleanup, валидирует timestampKind. «Переподключить live» создаёт новый request key в том же object scope, сохраняет последнее подтверждённое значение как historical до нового sample. При смене scope значения не переносятся. Connecting/error состояния явные; disabled retry during connecting предотвращает повторный запуск. Короткая подпись «Последнее live» помещается на320px, причина видна отдельной Alert. TimestampKind backend-receipt подписан «ответ RouterOS получен приложением»: at не выдаётся за часы устройства. Existing ReUI primitives/layout/theme сохранены.
| Состояние | Схема | Артефакт |
| --- | --- | --- |
| Source выключен / SSE закрыт | Причина → последнее значение → переподключить → новый sample | [ПК1440](ux-ui/screens/traffic-live-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-live-error-320-2026-10-12.png), API-fixture |
| Partial native response | Inventory → expected set → unknown/error вместо partial0 | Real TCP/HTTP owned SQL |
Unit tests parser/completeness/fallback/auth/source/abort/10-interface concurrency/positive-small-rate прошли; frontend formatter/projection, application-source TypeScript/backend build/ESLint прошли. Новая unit initially использовала undefined default argument вместо missing field; fixture исправлена.7 unique E2E (2new retry/cause/320/1440 +5 existing SSE), final pass13.3s. Screenshot scoped repeats2 не новые scenarios; visual review выявила clipped long hint, caption сокращена и final frontend пересобран. Hidden tooltip duplicates в production smoke selector исключены filter visible, functional UI не менялся ради теста.
Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/source+14history+10 live transport cases. Последние — actual Fastify TCP SSE и MikrotikClient HTTP к loopback simulator:0,empty,malformed,partial,complete fallback,failed fallback,401 terminal,disable after first sample/no further native requests,initial disabled404/no native requests,config change/closed stream. Fake RouterOS response не объявлен физической неисправностью CHR. Budget/concurrency refinement вызвала оправданный scoped rerun; финальный DB mm_accept_166_1791814796307/ограниченная role/container удалены, отсутствие проверено. Предыдущий временный mm_accept_166_1791813403915 также удалён trap. Production env/credentials/JWT не копировались. [SQL](ux-ui/acceptance/traffic-live-sql-2026-10-12.json).
[Source451 backend+522 frontend](ux-ui/acceptance/traffic-live-source-2026-10-12.json)/[1036 compiled](ux-ui/acceptance/traffic-live-compiled-2026-10-12.json) совпали. Production builds прошли; первый frontend build с длинной caption superseded до deploy. Staged frontend ux-live179 с backend ux-history178 [interop](ux-ui/acceptance/traffic-live-interop-2026-10-12.json), затем both ux-live179/version1.0.0-test.live179 [runtime](ux-ui/acceptance/traffic-live-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-history178 без secret env copy; storage migration не добавлялась.
[Настоящие 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.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-12 ## Актуальная точка продолжения — 2026-10-12
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты. Этапы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) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+24 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,29 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
## 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. Это не подтверждение максимальной производительности интерфейса.
Новый readTrafficLiveSample revalidates enabled/current config перед каждым новым native request и перед публикацией. Device disabled/deleted/config change закрывает SSE с причиной; HTTP401/403 закрывают поток без повторного опроса. Inventory обновляется каждый aggregate tick. Только joined HTTP400 при нескольких интерфейсах допускает per-interface fallback; failures не заглушаются пустыми массивами, partial sum не публикуется. Fallback≤4 concurrent commands, complete-wave budget8s; parent abort/deadline отменяет POST/ожидаемые продолжения. Существующий GET транспорт не принимает AbortSignal, его already-started read ограничен4s; после abort не запускаются следующие commands. Нельзя отозвать уже отправленную native команду. Multiple instances/large inventory/long WAN/revocation of already-established JWT streams отдельно не приняты.
Hook сохраняет конкретную причину error при end-of-stream, закрывает transport после decoding/HTTP failure и cleanup, валидирует timestampKind. «Переподключить live» создаёт новый request key в том же object scope, сохраняет последнее подтверждённое значение как historical до нового sample. При смене scope значения не переносятся. Connecting/error состояния явные; disabled retry during connecting предотвращает повторный запуск. Короткая подпись «Последнее live» помещается на320px, причина видна отдельной Alert. TimestampKind backend-receipt подписан «ответ RouterOS получен приложением»: at не выдаётся за часы устройства. Existing ReUI primitives/layout/theme сохранены.
| Состояние | Схема | Артефакт |
| --- | --- | --- |
| Source выключен / SSE закрыт | Причина → последнее значение → переподключить → новый sample | [ПК1440](ux-ui/screens/traffic-live-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-live-error-320-2026-10-12.png), API-fixture |
| Partial native response | Inventory → expected set → unknown/error вместо partial0 | Real TCP/HTTP owned SQL |
Unit tests parser/completeness/fallback/auth/source/abort/10-interface concurrency/positive-small-rate прошли; frontend formatter/projection, application-source TypeScript/backend build/ESLint прошли. Новая unit initially использовала undefined default argument вместо missing field; fixture исправлена.7 unique E2E (2new retry/cause/320/1440 +5 existing SSE), final pass13.3s. Screenshot scoped repeats2 не новые scenarios; visual review выявила clipped long hint, caption сокращена и final frontend пересобран. Hidden tooltip duplicates в production smoke selector исключены filter visible, functional UI не менялся ради теста.
Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/source+14history+10 live transport cases. Последние — actual Fastify TCP SSE и MikrotikClient HTTP к loopback simulator:0,empty,malformed,partial,complete fallback,failed fallback,401 terminal,disable after first sample/no further native requests,initial disabled404/no native requests,config change/closed stream. Fake RouterOS response не объявлен физической неисправностью CHR. Budget/concurrency refinement вызвала оправданный scoped rerun; финальный DB mm_accept_166_1791814796307/ограниченная role/container удалены, отсутствие проверено. Предыдущий временный mm_accept_166_1791813403915 также удалён trap. Production env/credentials/JWT не копировались. [SQL](ux-ui/acceptance/traffic-live-sql-2026-10-12.json).
[Source451 backend+522 frontend](ux-ui/acceptance/traffic-live-source-2026-10-12.json)/[1036 compiled](ux-ui/acceptance/traffic-live-compiled-2026-10-12.json) совпали. Production builds прошли; первый frontend build с длинной caption superseded до deploy. Staged frontend ux-live179 с backend ux-history178 [interop](ux-ui/acceptance/traffic-live-interop-2026-10-12.json), затем both ux-live179/version1.0.0-test.live179 [runtime](ux-ui/acceptance/traffic-live-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-history178 без secret env copy; storage migration не добавлялась.
[Настоящие 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.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12) ## 12.5.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12)
Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend. Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend.
+26 -3
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-12 # Оставшаяся внешняя приёмка — 2026-10-12
Это актуальная матрица после 12.5.178. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. Это актуальная матрица после 12.5.179. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-history178`, frontend `ux-history178`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. Стенд: 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) проверены; пароли не менялись.
| Раздел | Подтверждено | Осталось / условие завершения | | Раздел | Подтверждено | Осталось / условие завершения |
| --- | --- | --- | | --- | --- | --- |
@@ -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 миграция не принята | | 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 не приняты | | 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/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения | | 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; live incomplete/disable-stream179 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; volume precision/units180 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). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение. Доказательства и точные ограничения: [полная история этапа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). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
@@ -962,3 +962,26 @@ Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history contr
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy. [Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push. Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и 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. Это не подтверждение максимальной производительности интерфейса.
Новый readTrafficLiveSample revalidates enabled/current config перед каждым новым native request и перед публикацией. Device disabled/deleted/config change закрывает SSE с причиной; HTTP401/403 закрывают поток без повторного опроса. Inventory обновляется каждый aggregate tick. Только joined HTTP400 при нескольких интерфейсах допускает per-interface fallback; failures не заглушаются пустыми массивами, partial sum не публикуется. Fallback≤4 concurrent commands, complete-wave budget8s; parent abort/deadline отменяет POST/ожидаемые продолжения. Существующий GET транспорт не принимает AbortSignal, его already-started read ограничен4s; после abort не запускаются следующие commands. Нельзя отозвать уже отправленную native команду. Multiple instances/large inventory/long WAN/revocation of already-established JWT streams отдельно не приняты.
Hook сохраняет конкретную причину error при end-of-stream, закрывает transport после decoding/HTTP failure и cleanup, валидирует timestampKind. «Переподключить live» создаёт новый request key в том же object scope, сохраняет последнее подтверждённое значение как historical до нового sample. При смене scope значения не переносятся. Connecting/error состояния явные; disabled retry during connecting предотвращает повторный запуск. Короткая подпись «Последнее live» помещается на320px, причина видна отдельной Alert. TimestampKind backend-receipt подписан «ответ RouterOS получен приложением»: at не выдаётся за часы устройства. Existing ReUI primitives/layout/theme сохранены.
| Состояние | Схема | Артефакт |
| --- | --- | --- |
| Source выключен / SSE закрыт | Причина → последнее значение → переподключить → новый sample | [ПК1440](ux-ui/screens/traffic-live-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-live-error-320-2026-10-12.png), API-fixture |
| Partial native response | Inventory → expected set → unknown/error вместо partial0 | Real TCP/HTTP owned SQL |
Unit tests parser/completeness/fallback/auth/source/abort/10-interface concurrency/positive-small-rate прошли; frontend formatter/projection, application-source TypeScript/backend build/ESLint прошли. Новая unit initially использовала undefined default argument вместо missing field; fixture исправлена.7 unique E2E (2new retry/cause/320/1440 +5 existing SSE), final pass13.3s. Screenshot scoped repeats2 не новые scenarios; visual review выявила clipped long hint, caption сокращена и final frontend пересобран. Hidden tooltip duplicates в production smoke selector исключены filter visible, functional UI не менялся ради теста.
Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/source+14history+10 live transport cases. Последние — actual Fastify TCP SSE и MikrotikClient HTTP к loopback simulator:0,empty,malformed,partial,complete fallback,failed fallback,401 terminal,disable after first sample/no further native requests,initial disabled404/no native requests,config change/closed stream. Fake RouterOS response не объявлен физической неисправностью CHR. Budget/concurrency refinement вызвала оправданный scoped rerun; финальный DB mm_accept_166_1791814796307/ограниченная role/container удалены, отсутствие проверено. Предыдущий временный mm_accept_166_1791813403915 также удалён trap. Production env/credentials/JWT не копировались. [SQL](ux-ui/acceptance/traffic-live-sql-2026-10-12.json).
[Source451 backend+522 frontend](ux-ui/acceptance/traffic-live-source-2026-10-12.json)/[1036 compiled](ux-ui/acceptance/traffic-live-compiled-2026-10-12.json) совпали. Production builds прошли; первый frontend build с длинной caption superseded до deploy. Staged frontend ux-live179 с backend ux-history178 [interop](ux-ui/acceptance/traffic-live-interop-2026-10-12.json), затем both ux-live179/version1.0.0-test.live179 [runtime](ux-ui/acceptance/traffic-live-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-history178 без secret env copy; storage migration не добавлялась.
[Настоящие 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.
+24 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;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 открыта.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3352,3 +3352,26 @@ Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history contr
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy. [Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push. Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и 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. Это не подтверждение максимальной производительности интерфейса.
Новый readTrafficLiveSample revalidates enabled/current config перед каждым новым native request и перед публикацией. Device disabled/deleted/config change закрывает SSE с причиной; HTTP401/403 закрывают поток без повторного опроса. Inventory обновляется каждый aggregate tick. Только joined HTTP400 при нескольких интерфейсах допускает per-interface fallback; failures не заглушаются пустыми массивами, partial sum не публикуется. Fallback≤4 concurrent commands, complete-wave budget8s; parent abort/deadline отменяет POST/ожидаемые продолжения. Существующий GET транспорт не принимает AbortSignal, его already-started read ограничен4s; после abort не запускаются следующие commands. Нельзя отозвать уже отправленную native команду. Multiple instances/large inventory/long WAN/revocation of already-established JWT streams отдельно не приняты.
Hook сохраняет конкретную причину error при end-of-stream, закрывает transport после decoding/HTTP failure и cleanup, валидирует timestampKind. «Переподключить live» создаёт новый request key в том же object scope, сохраняет последнее подтверждённое значение как historical до нового sample. При смене scope значения не переносятся. Connecting/error состояния явные; disabled retry during connecting предотвращает повторный запуск. Короткая подпись «Последнее live» помещается на320px, причина видна отдельной Alert. TimestampKind backend-receipt подписан «ответ RouterOS получен приложением»: at не выдаётся за часы устройства. Existing ReUI primitives/layout/theme сохранены.
| Состояние | Схема | Артефакт |
| --- | --- | --- |
| Source выключен / SSE закрыт | Причина → последнее значение → переподключить → новый sample | [ПК1440](ux-ui/screens/traffic-live-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-live-error-320-2026-10-12.png), API-fixture |
| Partial native response | Inventory → expected set → unknown/error вместо partial0 | Real TCP/HTTP owned SQL |
Unit tests parser/completeness/fallback/auth/source/abort/10-interface concurrency/positive-small-rate прошли; frontend formatter/projection, application-source TypeScript/backend build/ESLint прошли. Новая unit initially использовала undefined default argument вместо missing field; fixture исправлена.7 unique E2E (2new retry/cause/320/1440 +5 existing SSE), final pass13.3s. Screenshot scoped repeats2 не новые scenarios; visual review выявила clipped long hint, caption сокращена и final frontend пересобран. Hidden tooltip duplicates в production smoke selector исключены filter visible, functional UI не менялся ради теста.
Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/source+14history+10 live transport cases. Последние — actual Fastify TCP SSE и MikrotikClient HTTP к loopback simulator:0,empty,malformed,partial,complete fallback,failed fallback,401 terminal,disable after first sample/no further native requests,initial disabled404/no native requests,config change/closed stream. Fake RouterOS response не объявлен физической неисправностью CHR. Budget/concurrency refinement вызвала оправданный scoped rerun; финальный DB mm_accept_166_1791814796307/ограниченная role/container удалены, отсутствие проверено. Предыдущий временный mm_accept_166_1791813403915 также удалён trap. Production env/credentials/JWT не копировались. [SQL](ux-ui/acceptance/traffic-live-sql-2026-10-12.json).
[Source451 backend+522 frontend](ux-ui/acceptance/traffic-live-source-2026-10-12.json)/[1036 compiled](ux-ui/acceptance/traffic-live-compiled-2026-10-12.json) совпали. Production builds прошли; первый frontend build с длинной caption superseded до deploy. Staged frontend ux-live179 с backend ux-history178 [interop](ux-ui/acceptance/traffic-live-interop-2026-10-12.json), затем both ux-live179/version1.0.0-test.live179 [runtime](ux-ui/acceptance/traffic-live-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-history178 без secret env copy; storage migration не добавлялась.
[Настоящие 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.
+25 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10 # Тестовый сервер UX/UI — 2026-10-10
Сервер161.104.46.222: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта. Сервер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 открыта.
| Параметр | Проверенное состояние | | Параметр | Проверенное состояние |
| --- | --- | | --- | --- |
@@ -14,7 +14,7 @@
| PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed | | PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data | | Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска | | Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска |
| Образы приложения | mmapp-test/backend:ux-history178 и mmapp-test/frontend:ux-history178; запущены | | Образы приложения | mmapp-test/backend:ux-live179 и mmapp-test/frontend:ux-live179; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example). Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -1778,3 +1778,26 @@ Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history contr
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy. [Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push. Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и 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. Это не подтверждение максимальной производительности интерфейса.
Новый readTrafficLiveSample revalidates enabled/current config перед каждым новым native request и перед публикацией. Device disabled/deleted/config change закрывает SSE с причиной; HTTP401/403 закрывают поток без повторного опроса. Inventory обновляется каждый aggregate tick. Только joined HTTP400 при нескольких интерфейсах допускает per-interface fallback; failures не заглушаются пустыми массивами, partial sum не публикуется. Fallback≤4 concurrent commands, complete-wave budget8s; parent abort/deadline отменяет POST/ожидаемые продолжения. Существующий GET транспорт не принимает AbortSignal, его already-started read ограничен4s; после abort не запускаются следующие commands. Нельзя отозвать уже отправленную native команду. Multiple instances/large inventory/long WAN/revocation of already-established JWT streams отдельно не приняты.
Hook сохраняет конкретную причину error при end-of-stream, закрывает transport после decoding/HTTP failure и cleanup, валидирует timestampKind. «Переподключить live» создаёт новый request key в том же object scope, сохраняет последнее подтверждённое значение как historical до нового sample. При смене scope значения не переносятся. Connecting/error состояния явные; disabled retry during connecting предотвращает повторный запуск. Короткая подпись «Последнее live» помещается на320px, причина видна отдельной Alert. TimestampKind backend-receipt подписан «ответ RouterOS получен приложением»: at не выдаётся за часы устройства. Existing ReUI primitives/layout/theme сохранены.
| Состояние | Схема | Артефакт |
| --- | --- | --- |
| Source выключен / SSE закрыт | Причина → последнее значение → переподключить → новый sample | [ПК1440](ux-ui/screens/traffic-live-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-live-error-320-2026-10-12.png), API-fixture |
| Partial native response | Inventory → expected set → unknown/error вместо partial0 | Real TCP/HTTP owned SQL |
Unit tests parser/completeness/fallback/auth/source/abort/10-interface concurrency/positive-small-rate прошли; frontend formatter/projection, application-source TypeScript/backend build/ESLint прошли. Новая unit initially использовала undefined default argument вместо missing field; fixture исправлена.7 unique E2E (2new retry/cause/320/1440 +5 existing SSE), final pass13.3s. Screenshot scoped repeats2 не новые scenarios; visual review выявила clipped long hint, caption сокращена и final frontend пересобран. Hidden tooltip duplicates в production smoke selector исключены filter visible, functional UI не менялся ради теста.
Owned Node22/PG18.6 profile test:traffic-rate24570ms без skip:9 counters/source+14history+10 live transport cases. Последние — actual Fastify TCP SSE и MikrotikClient HTTP к loopback simulator:0,empty,malformed,partial,complete fallback,failed fallback,401 terminal,disable after first sample/no further native requests,initial disabled404/no native requests,config change/closed stream. Fake RouterOS response не объявлен физической неисправностью CHR. Budget/concurrency refinement вызвала оправданный scoped rerun; финальный DB mm_accept_166_1791814796307/ограниченная role/container удалены, отсутствие проверено. Предыдущий временный mm_accept_166_1791813403915 также удалён trap. Production env/credentials/JWT не копировались. [SQL](ux-ui/acceptance/traffic-live-sql-2026-10-12.json).
[Source451 backend+522 frontend](ux-ui/acceptance/traffic-live-source-2026-10-12.json)/[1036 compiled](ux-ui/acceptance/traffic-live-compiled-2026-10-12.json) совпали. Production builds прошли; первый frontend build с длинной caption superseded до deploy. Staged frontend ux-live179 с backend ux-history178 [interop](ux-ui/acceptance/traffic-live-interop-2026-10-12.json), затем both ux-live179/version1.0.0-test.live179 [runtime](ux-ui/acceptance/traffic-live-runtime-2026-10-12.json) healthy/running/imageIds. Только.env tags, rollback both ux-history178 без secret env copy; storage migration не добавлялась.
[Настоящие 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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
{
"date": "2026-10-12",
"block": "12.5.179",
"runtime": {
"backend": "ux-live179",
"frontend": "ux-live179"
},
"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.179",
"newFrontend": "ux-live179",
"oldBackend": "ux-history178",
"readerAccepted": true,
"serverCount": 2,
"pageErrors": 0,
"bound": "Additive observed DTO reader compatibility; legacy numeric-only paths verified separately as unknown"
}
@@ -0,0 +1,32 @@
{
"date": "2026-10-12",
"block": "12.5.179",
"version": "1.0.0-test.live179",
"method": "Two actual application TCP SSE GET streams; native interface GET and monitor-traffic read-only POST; stop after first confirmed point",
"cases": [
{
"sourceId": 1,
"sample": {
"rxMbps": 0.031584,
"txMbps": 0.002016,
"at": "2026-10-12T14:31:58.084Z",
"timestampKind": "backend-receipt"
},
"streamStatus": 200
},
{
"sourceId": 2,
"sample": {
"rxMbps": 0.002112,
"txMbps": 0.002112,
"at": "2026-10-12T14:31:58.237Z",
"timestampKind": "backend-receipt"
},
"streamStatus": 200
}
],
"routerConfigWrites": 0,
"passwordChanges": 0,
"messages": 0,
"passed": true
}
@@ -0,0 +1,25 @@
{
"date": "2026-10-12",
"block": "12.5.179",
"secretEnvCopied": false,
"rollbackTags": {
"MMAPP_IMAGE_TAG": "ux-history178",
"BACKEND_IMAGE_TAG": "ux-history178"
},
"services": {
"backend": {
"image": "mmapp-test/backend:ux-live179",
"imageId": "sha256:34a5638656bfa98ef38440553735a7067e1040fd3422df37d157ed3124981d4c",
"state": "running",
"health": "healthy",
"matchedVerifiedImage": true
},
"frontend": {
"image": "mmapp-test/frontend:ux-live179",
"imageId": "sha256:af94a199a4c140c70769cb7b375940309a4fa169e734741648bd8cf1e27d14a3",
"state": "running",
"health": null,
"matchedVerifiedImage": true
}
}
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-12",
"block": "12.5.179",
"matched": {
"backend": 451,
"frontend": 522
},
"different": [],
"missing": []
}
@@ -0,0 +1,29 @@
{
"date": "2026-10-12",
"block": "12.5.179",
"passed": true,
"profiles": [
{
"date": "2026-10-12",
"block": "12.5.179",
"database": "mm_accept_166_1791814796307",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:traffic-rate",
"passed": true,
"durationMs": 24570,
"ownedTrafficSqlCases": 9,
"ownedHistorySqlCases": 14,
"ownedLiveSqlCases": 10,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+23 -11
View File
@@ -7,6 +7,7 @@ export interface TrafficLiveSample {
rxMbps: number rxMbps: number
txMbps: number txMbps: number
at: string at: string
timestampKind?: "backend-receipt"
} }
function parseSseBlock(block: string): { event: string; data: string } { function parseSseBlock(block: string): { event: string; data: string } {
@@ -24,18 +25,26 @@ export function useTrafficLive(opts: {
backendUrl: string backendUrl: string
serverId: string serverId: string
iface: string iface: string
reloadKey?: number
}): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } { }): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } {
const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.serverId, opts.iface]) const scope = JSON.stringify([opts.backendUrl, opts.serverId, opts.iface])
const [result, setResult] = useState<{ key: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null) const key = JSON.stringify([opts.enabled, scope, opts.reloadKey ?? 0])
const [result, setResult] = useState<{ key: string; scope: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null)
useEffect(() => { useEffect(() => {
if (!opts.enabled || !opts.serverId) return if (!opts.enabled || !opts.serverId) return
const ac = new AbortController() const ac = new AbortController()
let lastStreamError: string | null = null
// A new connection owns the status; the same device's previous sample remains historical.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResult(previous => ({ key, scope, sample: previous?.scope === scope ? previous.sample : null,
receivedAt: previous?.scope === scope ? previous.receivedAt : null, error: "Ожидание нового live-измерения" }))
let sampleTimer: ReturnType<typeof setTimeout> | undefined let sampleTimer: ReturnType<typeof setTimeout> | undefined
const stopTimer = () => clearTimeout(sampleTimer) const stopTimer = () => clearTimeout(sampleTimer)
const setError = (error: string) => { const setError = (error: string) => {
lastStreamError = error
stopTimer() stopTimer()
if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error })) if (!ac.signal.aborted) setResult(previous => ({ key, scope, sample: previous?.scope === scope ? previous.sample : null, receivedAt: previous?.scope === scope ? previous.receivedAt : null, error }))
} }
const ifaceQ = const ifaceQ =
opts.iface && opts.iface !== "__all__" opts.iface && opts.iface !== "__all__"
@@ -61,7 +70,8 @@ export function useTrafficLive(opts: {
}) })
if (ac.signal.aborted) return if (ac.signal.aborted) return
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
setError(`live HTTP ${res.status}`) const body = await res.json().catch(() => null) as { error?: unknown } | null
setError(`${typeof body?.error === "string" ? body.error : "Live-поток недоступен"} (HTTP ${res.status})`)
return return
} }
const reader = res.body.getReader() const reader = res.body.getReader()
@@ -70,7 +80,7 @@ export function useTrafficLive(opts: {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (ac.signal.aborted) return if (ac.signal.aborted) return
if (done) { if (done) {
setError("Поток live завершён") setError(lastStreamError ?? "Поток live завершён")
break break
} }
buf += decoder.decode(value, { stream: true }) buf += decoder.decode(value, { stream: true })
@@ -81,14 +91,15 @@ export function useTrafficLive(opts: {
const ev = parseSseBlock(raw) const ev = parseSseBlock(raw)
if (ev.event === "sample" && ev.data) { if (ev.event === "sample" && ev.data) {
const parsed = JSON.parse(ev.data) as TrafficLiveSample const parsed = JSON.parse(ev.data) as TrafficLiveSample
if (!parsed || !Number.isFinite(parsed.rxMbps) || !Number.isFinite(parsed.txMbps) || parsed.rxMbps < 0 || parsed.txMbps < 0 || typeof parsed.at !== "string" || !Number.isFinite(Date.parse(parsed.at))) { if (!parsed || !Number.isFinite(parsed.rxMbps) || !Number.isFinite(parsed.txMbps) || parsed.rxMbps < 0 || parsed.txMbps < 0 || typeof parsed.at !== "string" || !Number.isFinite(Date.parse(parsed.at)) || parsed.timestampKind !== undefined && parsed.timestampKind !== "backend-receipt") {
throw new Error("Некорректные данные live-потока") throw new Error("Некорректные данные live-потока")
} }
awaitSample() awaitSample()
setResult({ key, sample: parsed, receivedAt: Date.now(), error: null }) lastStreamError = null
setResult({ key, scope, sample: parsed, receivedAt: Date.now(), error: null })
} else if (ev.event === "error" && ev.data) { } else if (ev.event === "error" && ev.data) {
const parsed = JSON.parse(ev.data) as { error?: string } const parsed = JSON.parse(ev.data) as { error?: unknown } | null
setError(parsed.error ?? "live error") setError(typeof parsed?.error === "string" ? parsed.error : "Некорректный ответ об ошибке live-потока")
} }
} }
} }
@@ -97,11 +108,12 @@ export function useTrafficLive(opts: {
setError(e instanceof Error ? e.message : "live error") setError(e instanceof Error ? e.message : "live error")
} finally { } finally {
stopTimer() stopTimer()
ac.abort()
} }
})() })()
return () => { stopTimer(); ac.abort() } return () => { stopTimer(); ac.abort() }
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key]) }, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key, scope])
return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null } return opts.enabled && result?.scope === scope ? { sample: result.sample, error: result.key === key ? result.error : "Ожидание нового live-измерения", receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null }
} }
+6
View File
@@ -0,0 +1,6 @@
import assert from "node:assert/strict"
import { fmtRate } 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")
+1
View File
@@ -2,6 +2,7 @@
export function fmtRate(v: number | null | undefined): string { export function fmtRate(v: number | null | undefined): string {
if (v == null) return "—" if (v == null) return "—"
if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с" if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с"
if (v < 0.0005) return "<1 Кбит/с"
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с` if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
if (v < 1) return `${Math.round(v * 1000)} Кбит/с` if (v < 1) return `${Math.round(v * 1000)} Кбит/с`
if (v < 10) return `${v.toFixed(2)} Мбит/с` if (v < 10) return `${v.toFixed(2)} Мбит/с`
+40
View File
@@ -0,0 +1,40 @@
import { test, expect, type Page } from "@playwright/test"
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
async function setup(page: Page, terminal = true) {
const control = { calls: 0, release: () => {} }
const gate = new Promise<void>(resolve => { control.release = resolve })
const server = { id:"1",name:"CHR live",site:"TEST",country:"RU",status:"online",rxNow:2,txNow:3,rxPeak:4,txPeak:5,rxTotal:1,txTotal:2,sessions:1,rxSeries:[1,2],txSeries:[2,3] }
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",async r=>{
const path=new URL(r.request().url()).pathname
if(path.endsWith("/live")) {
control.calls++
if(control.calls>1) await gate
const sample={rxMbps:control.calls===1?17:0,txMbps:0,at:"2026-10-12T12:00:00Z",timestampKind:"backend-receipt"}
const end=terminal&&control.calls===1?'event: error\ndata: '+JSON.stringify({error:"Устройство выключено. Live-поток остановлен"})+'\n\n':''
return r.fulfill({contentType:"text/event-stream",body:'event: sample\ndata: '+JSON.stringify(sample)+'\n\n'+end})
}
return r.fulfill({json:observedTrafficFixturePayload(path==="/api/auth/config"?{required:false}:path==="/api/traffic/servers"?{servers:[server]}:path==="/api/traffic/servers/1"?{server}:path.endsWith("/interfaces")||path==="/api/traffic/bound-interfaces"?{interfaces:[]}:path==="/api/traffic/users"?{users:[]}:path==="/api/servers"?[]:{})})
})
await page.goto("/traffic")
await expect(page.getByRole("heading",{name:"CHR live",exact:true})).toBeVisible()
return control
}
for(const width of [1440,320])test(`Причина закрытия live сохранена, переподключение сохраняет последнее значение ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000});const c=await setup(page)
const status=page.getByRole("status").filter({hasText:"Live-поток: нет свежих данных"})
await expect(status).toContainText("Устройство выключено")
await expect(status).not.toContainText("Поток live завершён")
await expect(page.getByLabel("Скорость выбранного сервера")).toContainText("17.0")
await expect(page.getByLabel("Последнее измерение сервера")).toContainText("ответ RouterOS получен приложением")
await status.scrollIntoViewIfNeeded()
await status.evaluate(el => { let parent=el.parentElement;while(parent&&(!/auto|scroll/.test(getComputedStyle(parent).overflowY)||parent.scrollHeight<=parent.clientHeight))parent=parent.parentElement;if(parent)parent.scrollTop+=el.getBoundingClientRect().top-parent.getBoundingClientRect().top-24 })
await page.screenshot({path:`docs/ux-ui/screens/traffic-live-error-${width}-2026-10-12.png`,fullPage:true})
await page.getByRole("button",{name:"Переподключить live",exact:true}).click()
await expect.poll(()=>c.calls).toBe(2)
await expect(page.getByRole("status").filter({hasText:"Подключение live-потока"})).toBeVisible()
await expect(page.getByLabel("Скорость выбранного сервера")).toContainText("17.0")
c.release();await expect(page.getByLabel("Скорость выбранного сервера")).not.toContainText("17.0")
await expect(page.getByLabel("Скорость выбранного сервера").getByText("0 Кбит/с",{exact:true})).toHaveCount(2)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true)
})
+1 -1
View File
@@ -62,7 +62,7 @@ for (const variant of ['closed', 'invalid', 'http-error', 'silent', 'awaiting']
await expect(page.getByLabel('Последнее измерение сервера')).toHaveCount(0) await expect(page.getByLabel('Последнее измерение сервера')).toHaveCount(0)
return return
} }
const hint = variant === 'http-error' ? 'История · live недоступен' : 'Последнее live-измерение · поток недоступен' const hint = variant === 'http-error' ? 'История · live недоступен' : 'Последнее live'
await expect(summary.getByText(hint, { exact: true }).filter({ visible: true })).toHaveCount(2) await expect(summary.getByText(hint, { exact: true }).filter({ visible: true })).toHaveCount(2)
await expect(summary.getByText('live', { exact: true })).toHaveCount(0) await expect(summary.getByText('live', { exact: true })).toHaveCount(0)
await expect(page.getByLabel('Получение истории сервера').locator('time')).toHaveAttribute('datetime', received.toISOString()) await expect(page.getByLabel('Получение истории сервера').locator('time')).toHaveAttribute('datetime', received.toISOString())