fix(traffic): проверять live-срез и прекращать опрос выключенных устройств
This commit is contained in:
1 parent
1275dd7b9b
commit
2358ecef60
29 files changed
+1620
-114
No files matched your search
@@ -793,6 +793,7 @@ export default function TrafficPage() {
|
||||
const [range, setRange] = useState<Range>("1h")
|
||||
const [search, setSearch] = useState("")
|
||||
const [trafficRetry, setTrafficRetry] = useState(0)
|
||||
const [liveRetry, setLiveRetry] = useState(0)
|
||||
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 trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready"
|
||||
@@ -843,6 +844,7 @@ export default function TrafficPage() {
|
||||
backendUrl,
|
||||
serverId: selectedId,
|
||||
iface: selectedIface,
|
||||
reloadKey: liveRetry,
|
||||
})
|
||||
const confirmedFlowSelection = isLive && Boolean(currentFlows) && !liveError && (
|
||||
range === "30d"
|
||||
@@ -1472,7 +1474,14 @@ export default function TrafficPage() {
|
||||
</>}
|
||||
{isLive && effectiveMode === "servers" && detailServer && <>
|
||||
<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 && (
|
||||
<ServerDetail
|
||||
@@ -1481,8 +1490,9 @@ export default function TrafficPage() {
|
||||
setRange={setRange}
|
||||
liveRx={liveSample?.rxMbps}
|
||||
liveTx={liveSample?.txMbps}
|
||||
liveHint={liveStreamError
|
||||
? (liveSample ? "Последнее live-измерение · поток недоступен" : "История · live недоступен")
|
||||
liveHint={liveStreamError === "Ожидание нового live-измерения"
|
||||
? (liveSample ? "Последнее live" : "История · ожидание live") : liveStreamError
|
||||
? (liveSample ? "Последнее live" : "История · live недоступен")
|
||||
: liveSample ? "live" : isLive ? "История · ожидание live" : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
|
||||
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
|
||||
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
|
||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-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: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",
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "../services/traffic-collector.js"
|
||||
import { refreshScheduler, runSchedulerJobNow } from "../services/scheduler.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 { readTrafficCollectionContext } from "../services/traffic-collection-state.js"
|
||||
import { applyTrafficFreshness } from "../services/traffic-freshness.js"
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
buildTrafficFromSamples,
|
||||
type TrafficObservations,
|
||||
isLoopbackName,
|
||||
parseMonitorTraffic,
|
||||
} from "../services/traffic-rate.js"
|
||||
import {
|
||||
buildBoundInterfaceTraffic,
|
||||
@@ -54,7 +54,6 @@ interface TrafficInterfaceDto {
|
||||
}
|
||||
|
||||
const LIVE_TICK_MS = 1500
|
||||
const LIVE_ROS_TIMEOUT_MS = 4000
|
||||
|
||||
async function latestSnapshot(serverId: number) {
|
||||
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) => {
|
||||
app.get("/traffic/settings", async (_req, reply) => {
|
||||
const settings = await getTrafficSettings()
|
||||
@@ -344,13 +281,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
const raw = await monitorTrafficOnce(client, onlyInterface, cache, abort.signal)
|
||||
const sample = parseMonitorTraffic(raw, { onlyInterface })
|
||||
const sample = await readTrafficLiveSample(client, onlyInterface, cache, abort.signal, async () => {
|
||||
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)
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) break
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -157,8 +157,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
rxBps: Math.round(rxBps),
|
||||
txBps: Math.round(txBps),
|
||||
flags: encodeTrafficFlags(running, disabled, true),
|
||||
}
|
||||
})
|
||||
@@ -186,8 +186,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
sampledAt: now,
|
||||
rxBytes,
|
||||
txBytes,
|
||||
rxBps,
|
||||
txBps,
|
||||
rxBps: Math.round(rxBps),
|
||||
txBps: Math.round(txBps),
|
||||
flags: encodeTrafficFlags(running, disabled, true),
|
||||
})
|
||||
}
|
||||
@@ -205,8 +205,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
|
||||
ok: true,
|
||||
interfaces: ifaces.length,
|
||||
...(includedInterfaces > 0 && totalsMeasured ? {
|
||||
sumRxMbps: Math.round(sumRxMbps * 1000) / 1000,
|
||||
sumTxMbps: Math.round(sumTxMbps * 1000) / 1000,
|
||||
sumRxMbps,
|
||||
sumTxMbps,
|
||||
} : {}),
|
||||
})
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface MonitorLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
timestampKind: "backend-receipt"
|
||||
}
|
||||
|
||||
export function isLoopbackName(name: string): boolean {
|
||||
@@ -61,7 +62,7 @@ export function shouldIncludeIface(
|
||||
|
||||
export function bpsToMbps(bps: number): number {
|
||||
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 или сброс). */
|
||||
@@ -75,7 +76,7 @@ export function rateBpsFromDelta(
|
||||
const dtSec = (nextAtMs - prevAtMs) / 1000
|
||||
if (!(dtSec > 0) || !Number.isFinite(dtSec)) 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
|
||||
}
|
||||
|
||||
@@ -274,26 +275,41 @@ export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeri
|
||||
|
||||
export function parseMonitorTraffic(
|
||||
raw: unknown,
|
||||
opts?: { onlyInterface?: string },
|
||||
opts?: { onlyInterface?: string; expectedInterfaces?: readonly string[] },
|
||||
): MonitorLiveSample {
|
||||
const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []
|
||||
let rxBps = 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) {
|
||||
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 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 (name && name !== opts.onlyInterface) continue
|
||||
if (name !== opts.onlyInterface) throw new Error("Ответ monitor-traffic относится к другому интерфейсу")
|
||||
} else if (isLoopbackName(name)) {
|
||||
continue
|
||||
}
|
||||
rxBps += Number.parseFloat(String(rec["rx-bits-per-second"] ?? 0)) || 0
|
||||
txBps += Number.parseFloat(String(rec["tx-bits-per-second"] ?? 0)) || 0
|
||||
if (seen.has(name) || expected && !expected.has(name)) throw new Error("Некорректный набор интерфейсов monitor-traffic")
|
||||
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 {
|
||||
rxMbps: bpsToMbps(rxBps),
|
||||
txMbps: bpsToMbps(txBps),
|
||||
rxMbps: rxBps / 1_000_000,
|
||||
txMbps: txBps / 1_000_000,
|
||||
at: new Date().toISOString(),
|
||||
timestampKind: "backend-receipt",
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/** 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
|
||||
receivedAt?: number | null
|
||||
source: "API" | "live-поток"
|
||||
serverAt?: string
|
||||
serverTimeLabel?: string
|
||||
}) {
|
||||
if (receivedAt == null || !Number.isFinite(receivedAt)) return null
|
||||
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">
|
||||
Получено ({source}): <time dateTime={date.toISOString()}>{date.toLocaleString("ru-RU")}</time>
|
||||
{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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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).
|
||||
|
||||
@@ -696,7 +696,7 @@ flowchart LR
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Следующий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
@@ -1,12 +1,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 открыта.
|
||||
|
||||
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||
|
||||
## Актуальная точка продолжения — 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 и оставшиеся профильные сетевые ограничения открыты.
|
||||
|
||||
@@ -371,6 +371,6 @@ flowchart LR
|
||||
|
||||
## 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).
|
||||
+24
-1
@@ -1,6 +1,6 @@
|
||||
# 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).
|
||||
|
||||
@@ -38,6 +38,29 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 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)
|
||||
|
||||
Подтверждены 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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Оставшаяся внешняя приёмка — 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 миграция не принята |
|
||||
| Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты |
|
||||
| Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения |
|
||||
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; history178/legacy provenance/current TTL/manual truth/SQL9+14/35unique E2E/native GET/deployed passed; 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). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Следующий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
@@ -1,6 +1,6 @@
|
||||
# Этап 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Следующий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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Тестовый сервер 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 |
|
||||
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
|
||||
| Доступ | 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Следующий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
@@ -7,6 +7,7 @@ export interface TrafficLiveSample {
|
||||
rxMbps: number
|
||||
txMbps: number
|
||||
at: string
|
||||
timestampKind?: "backend-receipt"
|
||||
}
|
||||
|
||||
function parseSseBlock(block: string): { event: string; data: string } {
|
||||
@@ -24,18 +25,26 @@ export function useTrafficLive(opts: {
|
||||
backendUrl: string
|
||||
serverId: string
|
||||
iface: string
|
||||
reloadKey?: number
|
||||
}): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } {
|
||||
const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
|
||||
const [result, setResult] = useState<{ key: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null)
|
||||
const scope = JSON.stringify([opts.backendUrl, opts.serverId, opts.iface])
|
||||
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(() => {
|
||||
if (!opts.enabled || !opts.serverId) return
|
||||
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
|
||||
const stopTimer = () => clearTimeout(sampleTimer)
|
||||
const setError = (error: string) => {
|
||||
lastStreamError = error
|
||||
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 =
|
||||
opts.iface && opts.iface !== "__all__"
|
||||
@@ -61,7 +70,8 @@ export function useTrafficLive(opts: {
|
||||
})
|
||||
if (ac.signal.aborted) return
|
||||
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
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
@@ -70,7 +80,7 @@ export function useTrafficLive(opts: {
|
||||
const { done, value } = await reader.read()
|
||||
if (ac.signal.aborted) return
|
||||
if (done) {
|
||||
setError("Поток live завершён")
|
||||
setError(lastStreamError ?? "Поток live завершён")
|
||||
break
|
||||
}
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
@@ -81,14 +91,15 @@ export function useTrafficLive(opts: {
|
||||
const ev = parseSseBlock(raw)
|
||||
if (ev.event === "sample" && ev.data) {
|
||||
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-потока")
|
||||
}
|
||||
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) {
|
||||
const parsed = JSON.parse(ev.data) as { error?: string }
|
||||
setError(parsed.error ?? "live error")
|
||||
const parsed = JSON.parse(ev.data) as { error?: unknown } | null
|
||||
setError(typeof parsed?.error === "string" ? parsed.error : "Некорректный ответ об ошибке live-потока")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,11 +108,12 @@ export function useTrafficLive(opts: {
|
||||
setError(e instanceof Error ? e.message : "live error")
|
||||
} finally {
|
||||
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 }
|
||||
}
|
||||
@@ -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")
|
||||
@@ -2,6 +2,7 @@
|
||||
export function fmtRate(v: number | null | undefined): string {
|
||||
if (v == null) return "—"
|
||||
if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с"
|
||||
if (v < 0.0005) return "<1 Кбит/с"
|
||||
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
|
||||
if (v < 1) return `${Math.round(v * 1000)} Кбит/с`
|
||||
if (v < 10) return `${v.toFixed(2)} Мбит/с`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -62,7 +62,7 @@ for (const variant of ['closed', 'invalid', 'http-error', 'silent', 'awaiting']
|
||||
await expect(page.getByLabel('Последнее измерение сервера')).toHaveCount(0)
|
||||
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('live', { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByLabel('Получение истории сервера').locator('time')).toHaveAttribute('datetime', received.toISOString())
|
||||
|
||||
Reference in new issue
Block a user