fix(traffic): подтверждать идентичность соседних счётчиков

This commit is contained in:
Denozordec committed 2026-10-13 01:19:22 +07:00
1 parent 493c30bebc
commit b57ae316ba
27 files changed
+1444 -36

No files matched your search

@@ -0,0 +1 @@
ALTER TABLE traffic_samples ADD COLUMN IF NOT EXISTS native_counter_identity TEXT;
+1 -1
View File
@@ -19,7 +19,7 @@
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts && tsx src/services/traffic-binding-status.test.ts && tsx src/services/traffic-status-sql.test.ts && tsx src/services/traffic-gap.test.ts && tsx src/services/traffic-identity.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",
+1
View File
@@ -310,6 +310,7 @@ export const flowAsnMeta = pgTable("flow_asn_meta", {
})
export const trafficSamples = pgTable("traffic_samples", {
nativeCounterIdentity: text("native_counter_identity"),
serverId: intPkRef().references(() => servers.id, { onDelete: "cascade" }),
interfaceName: text("interface_name").notNull(),
peerPublicKey: text("peer_public_key").notNull().default(""),
+9 -1
View File
@@ -2,11 +2,19 @@ export const TRAFFIC_FLAG_RUNNING = 1
export const TRAFFIC_FLAG_DISABLED = 2
export const TRAFFIC_FLAG_COUNTERS_KNOWN = 4
export const TRAFFIC_FLAG_STATUS_KNOWN = 8
export const TRAFFIC_FLAG_CONTINUITY_KNOWN = 16
export const TRAFFIC_FLAG_CONTINUITY_RECORDED = 32
export function encodeTrafficFlags(running: boolean, disabled: boolean, countersKnown = false, statusKnown = false): number {
export function encodeTrafficFlags(running: boolean, disabled: boolean, countersKnown = false, statusKnown = false, continuityKnown?: boolean): number {
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
| (countersKnown ? TRAFFIC_FLAG_COUNTERS_KNOWN : 0)
| (statusKnown ? TRAFFIC_FLAG_STATUS_KNOWN : 0)
| (continuityKnown === undefined ? 0 : TRAFFIC_FLAG_CONTINUITY_RECORDED | (continuityKnown ? TRAFFIC_FLAG_CONTINUITY_KNOWN : 0))
}
/** Undefined retains the documented legacy boundary; new producers explicitly mark both outcomes. */
export function trafficCounterContinuity(flags: number): boolean | undefined {
return flags & TRAFFIC_FLAG_CONTINUITY_RECORDED ? !!(flags & TRAFFIC_FLAG_CONTINUITY_KNOWN) : undefined
}
export function trafficFlagRunning(flags: number | null | undefined): boolean {
+18 -9
View File
@@ -1,12 +1,12 @@
import { and, asc, desc, eq, gte } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers, trafficSamples, trafficSettings } from "../db/schema.js"
import { decodeTrafficSampleFlags, encodeTrafficFlags } from "../db/traffic-flags.js"
import { decodeTrafficSampleFlags, encodeTrafficFlags, trafficCounterContinuity } from "../db/traffic-flags.js"
import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js"
import { bpsToMbps, shouldIncludeIface } from "./traffic-rate.js"
import { observedTrafficRate, parseTrafficCounter, parseTrafficFlag } from "./traffic-observation.js"
import { observedTrafficRate, parseTrafficCounter, parseTrafficFlag, nativeTrafficCounterIdentity } from "./traffic-observation.js"
import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
import { recordTrafficCollectionAttempt } from "./traffic-collection-state.js"
import { parseTrafficPeers } from "./traffic-peer-observation.js"
@@ -38,14 +38,15 @@ function waveKey(interfaceName: string, peerPublicKey = ""): string {
}
function sampleRate(
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>,
prevWave: Map<string, { rxBytes: number; txBytes: number; sampledAt: string; nativeCounterIdentity: string|null }>,
key: string,
rxBytes: number,
txBytes: number,
nowMs: number,
maxIntervalMs: number,
identity: string|null,
): { rxBps: number; txBps: number; measured: boolean } {
return observedTrafficRate(prevWave.get(key), rxBytes, txBytes, nowMs, maxIntervalMs)
return observedTrafficRate(identity !== null && prevWave.get(key)?.nativeCounterIdentity === identity ? prevWave.get(key) : undefined, rxBytes, txBytes, nowMs, maxIntervalMs)
}
export interface TrafficCollectorState {
@@ -75,7 +76,7 @@ async function getSettingsRow() {
return (await db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1))[0]
}
async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes: number; txBytes: number; sampledAt: string }>> {
async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes: number; txBytes: number; sampledAt: string; nativeCounterIdentity: string|null }>> {
const last = (await db
.select({ sampledAt: trafficSamples.sampledAt })
.from(trafficSamples)
@@ -90,6 +91,7 @@ async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes
rxBytes: trafficSamples.rxBytes,
txBytes: trafficSamples.txBytes,
flags: trafficSamples.flags,
nativeCounterIdentity: trafficSamples.nativeCounterIdentity,
sampledAt: trafficSamples.sampledAt,
})
.from(trafficSamples)
@@ -144,11 +146,13 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
let totalsMeasured = true
const rows = ifaces.map((i) => {
const interfaceName = i.name!
const nativeCounterIdentity = nativeTrafficCounterIdentity(srv.updatedAt,i[".id"])
const continuityKnown = nativeCounterIdentity !== null && prevWave.get(waveKey(interfaceName))?.nativeCounterIdentity === nativeCounterIdentity
const rxBytes = toNum(i["rx-byte"])
const txBytes = toNum(i["tx-byte"])
const running = parseTrafficFlag(i.running)
const disabled = parseTrafficFlag(i.disabled)
const { rxBps, txBps, measured } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs, maxIntervalMs)
const { rxBps, txBps, measured } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs, maxIntervalMs, nativeCounterIdentity)
if (shouldIncludeIface(interfaceName, running, disabled)) {
includedInterfaces++
totalsMeasured &&= measured
@@ -164,7 +168,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes,
rxBps: Math.round(rxBps),
txBps: Math.round(txBps),
flags: encodeTrafficFlags(running, disabled, true, true),
flags: encodeTrafficFlags(running, disabled, true, true, continuityKnown),
nativeCounterIdentity,
}
})
let peerWarning: string | undefined
@@ -172,6 +177,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
const peers = await client.get<RosWgPeerTraffic[]>("/interface/wireguard/peers")
for (const p of parseTrafficPeers(peers)) {
const { interfaceName, peerPublicKey, rxBytes, txBytes, disabled } = p
const nativeCounterIdentity = nativeTrafficCounterIdentity(srv.updatedAt,p.nativeId)
const continuityKnown = nativeCounterIdentity !== null && prevWave.get(waveKey(interfaceName,peerPublicKey))?.nativeCounterIdentity === nativeCounterIdentity
const running = !disabled
const { rxBps, txBps } = sampleRate(
prevWave,
@@ -180,6 +187,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes,
nowMs,
maxIntervalMs,
nativeCounterIdentity,
)
rows.push({
serverId: srv.id,
@@ -190,7 +198,8 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes,
rxBps: Math.round(rxBps),
txBps: Math.round(txBps),
flags: encodeTrafficFlags(running, disabled, true, true),
flags: encodeTrafficFlags(running, disabled, true, true, continuityKnown),
nativeCounterIdentity,
})
}
} catch (error) {
@@ -292,5 +301,5 @@ export async function readServerSamplesInRange(serverId: number, sinceIso: strin
gte(trafficSamples.sampledAt, sinceIso),
))
.orderBy(asc(trafficSamples.sampledAt))
return rows.map((r) => ({ ...r, ...decodeTrafficSampleFlags(r.flags) }))
return rows.map((r) => ({ ...r, ...decodeTrafficSampleFlags(r.flags), continuityKnown: trafficCounterContinuity(r.flags) }))
}
@@ -16,7 +16,7 @@ else {
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name,database)
const prefix="own-history178-"+Date.now()+"-",userId=prefix+"user"
let serverId:number|undefined,deny=false,counter="1000",cases=0,requests=0
const http=createServer((req,res)=>{requests++;res.setHeader("Content-Type","application/json");if(deny){res.statusCode=401;res.end('{"message":"Unauthorized"}')}else res.end(JSON.stringify(req.url?.includes("/wireguard/")?[]:[{name:"ether1",running:"true",disabled:"false","rx-byte":counter,"tx-byte":counter}]))})
const http=createServer((req,res)=>{requests++;res.setHeader("Content-Type","application/json");if(deny){res.statusCode=401;res.end('{"message":"Unauthorized"}')}else res.end(JSON.stringify(req.url?.includes("/wireguard/")?[]:[{".id":"*1",name:"ether1",running:"true",disabled:"false","rx-byte":counter,"tx-byte":counter}]))})
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 get=async(path:string)=>{const r=await app.inject({method:"GET",url:path});assert.equal(r.statusCode,200);return r.json()}
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import {nativeTrafficCounterIdentity} from './traffic-observation.js'
import {buildTrafficFromSamples} from './traffic-rate.js'
import {encodeTrafficFlags,trafficCounterContinuity} from '../db/traffic-flags.js'
const at=Date.parse('2026-10-13T00:00:00Z'),iso=new Date(at).toISOString(),identity=nativeTrafficCounterIdentity(iso,'*a')!
assert.equal(identity,nativeTrafficCounterIdentity(new Date(at),'*A'))
for(const id of[undefined,null,'','bad','*z',{},1])assert.equal(nativeTrafficCounterIdentity(iso,id),null)
assert.equal(trafficCounterContinuity(13),undefined);assert.equal(trafficCounterContinuity(encodeTrafficFlags(true,false,true,true,false)),false);assert.equal(trafficCounterContinuity(encodeTrafficFlags(true,false,true,true,true)),true)
const first={interfaceName:'ether1',sampledAt:new Date(at-30_000).toISOString(),rxBytes:1000,txBytes:1000,rxBps:0,txBps:0,running:true,disabled:false,countersKnown:true,nativeCounterIdentity:identity,continuityKnown:false}
for(const nextIdentity of[identity,nativeTrafficCounterIdentity(iso,'*b'),nativeTrafficCounterIdentity(new Date(at+1),'*a'),null]){
const cur={...first,sampledAt:iso,rxBytes:2000,txBytes:2000,nativeCounterIdentity:nextIdentity,continuityKnown:nextIdentity===identity}
const built=buildTrafficFromSamples([first,cur],at-30_000,at,undefined,undefined,true)
assert.equal(built.observations!.rxNow,nextIdentity===identity);assert.equal(built.rxTotalGiB,nextIdentity===identity?1000/1024**3:0)
}
console.log('native counter identity/config/legacy marker/pair guards passed')
@@ -15,8 +15,8 @@ else {
let serverId: number | undefined, counters: unknown = "1000", second = false, cases = 0, requests = 0
const http = createServer((req, res) => {
requests++; res.setHeader("Content-Type", "application/json")
const rows = [{ name: "ether1", running: "true", disabled: "false", "rx-byte": counters, "tx-byte": counters }]
if (second) rows.push({ name: "ether2", running: "true", disabled: "false", "rx-byte": "1000", "tx-byte": "1000" })
const rows = [{ ".id":"*1",name: "ether1", running: "true", disabled: "false", "rx-byte": counters, "tx-byte": counters }]
if (second) rows.push({ ".id":"*2",name: "ether2", running: "true", disabled: "false", "rx-byte": "1000", "tx-byte": "1000" })
res.end(JSON.stringify(req.url?.includes("/wireguard/") ? [] : rows))
})
await new Promise<void>(resolve => http.listen(0, "127.0.0.1", resolve))
@@ -1,5 +1,6 @@
import {parseTrafficPeers} from "./traffic-peer-observation.js"
import assert from "node:assert/strict"
import { observedTrafficRate, parseTrafficCounter, parseTrafficFlag } from "./traffic-observation.js"
import { observedTrafficRate, parseTrafficCounter, parseTrafficFlag, nativeTrafficCounterIdentity } from "./traffic-observation.js"
import { collectSourceSignals } from "./alert-engine/collector-source-signals.js"
import fs from "node:fs"
import vm from "node:vm"
@@ -38,18 +39,18 @@ const ast = ts.createSourceFile("traffic-collector.ts", collector, ts.ScriptTarg
const functions = ast.statements.filter(n => ts.isFunctionDeclaration(n) && n.name &&
["waveKey", "sampleRate", "toNum", "collectTrafficOnce"].includes(n.name.text)).map(n => n.getText(ast)).join("\n")
assert.equal(ast.statements.filter(n => ts.isFunctionDeclaration(n) && n.name && n.name.text === "collectTrafficOnce").length, 1)
let inserted: unknown[] = [], wave = new Map<string, typeof previous>(), counters: unknown = "1000"
let inserted: unknown[] = [], wave = new Map<string, typeof previous & {nativeCounterIdentity?:string|null}>(), counters: unknown = "1000"
let included = true
const context = vm.createContext({
Date, observedTrafficRate, parseTrafficCounter, parseTrafficFlag, bpsToMbps, shouldIncludeIface,
Date, nativeTrafficCounterIdentity,parseTrafficPeers, observedTrafficRate, parseTrafficCounter, parseTrafficFlag, bpsToMbps, shouldIncludeIface,
SCHEDULER_RUN_SNAPSHOT_VERSION: 1, servers: {}, trafficSamples: {}, trafficSettings: {}, eq: () => ({}),
getSettingsRow: async () => ({}), readPreviousWave: async () => wave, rememberServerIfaces: () => {},
encodeTrafficFlags: () => 1,
recordTrafficCollectionAttempt: async () => {},
MikrotikClient: { fromServer: () => ({ get: async (path: string) => path === "/interface" ?
[{ name: "ether1", running: String(included), disabled: "false", "rx-byte": counters, "tx-byte": counters }] : [] }) },
[{ ".id":"*1",name: "ether1", running: String(included), disabled: "false", "rx-byte": counters, "tx-byte": counters }] : [] }) },
db: {
select: () => ({ from: () => ({ where: async () => [{ id: 1, name: "audit", host: "audit.example", enabled: true }] }) }),
select: () => ({ from: () => ({ where: async () => [{ updatedAt:iso,id: 1, name: "audit", host: "audit.example", enabled: true }] }) }),
insert: () => ({ values: async (rows: unknown[]) => { inserted.push(...rows) } }),
update: () => ({ set: () => ({ where: async () => {} }) }),
},
@@ -58,7 +59,7 @@ vm.runInContext(ts.transpileModule("let collecting=false;\n" + functions.replace
"\nglobalThis.run=collectTrafficOnce;", { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText, context)
let snapshot = await context.run()
assert.equal(snapshot.servers[0].ok, true); assert.equal(snapshot.servers[0].sumRxMbps, undefined); assert.equal(inserted.length, 1)
wave = new Map([["ether1\0", { ...previous, sampledAt: new Date(Date.now() - 30_000).toISOString() }]])
wave = new Map([["ether1\0", { ...previous, nativeCounterIdentity:nativeTrafficCounterIdentity(iso,"*1"),sampledAt: new Date(Date.now() - 30_000).toISOString() }]])
snapshot = await context.run(); assert.equal(snapshot.servers[0].sumRxMbps, 0)
counters = "10"; snapshot = await context.run(); assert.equal(snapshot.servers[0].sumRxMbps, undefined)
counters = "bad"; inserted = []; snapshot = await context.run()
@@ -1,4 +1,9 @@
import { rateBpsFromDelta } from "./traffic-rate.js"
export function nativeTrafficCounterIdentity(updatedAt: string | Date, nativeId: unknown): string | null {
const at = updatedAt instanceof Date ? updatedAt.getTime() : Date.parse(updatedAt)
if (!Number.isFinite(at) || typeof nativeId !== "string" || !/^\*[0-9a-f]+$/i.test(nativeId)) return null
return JSON.stringify([new Date(at).toISOString(), nativeId.toUpperCase()])
}
export function parseTrafficFlag(raw: unknown): boolean {
if (raw === true || raw === "true" || raw === "yes") return true
if (raw === false || raw === "false" || raw === "no") return false
@@ -13,6 +13,6 @@ export function parseTrafficPeers(raw: unknown) {
const key = `${interfaceName}\0${peerPublicKey}`
if (seen.has(key)) throw new Error("Повторное измерение WireGuard peer")
seen.add(key)
return { interfaceName, peerPublicKey, rxBytes: parseTrafficCounter(p.rx), txBytes: parseTrafficCounter(p.tx), disabled: parseTrafficFlag(p.disabled) }
return { interfaceName, peerPublicKey, nativeId: p[".id"], rxBytes: parseTrafficCounter(p.rx), txBytes: parseTrafficCounter(p.tx), disabled: parseTrafficFlag(p.disabled) }
})
}
+4 -1
View File
@@ -14,6 +14,8 @@ export interface TrafficSampleLike {
running: boolean
disabled: boolean
countersKnown?: boolean
continuityKnown?: boolean
nativeCounterIdentity?: string | null
}
export interface RatePoint {
@@ -180,7 +182,8 @@ export function buildTrafficFromSamples(
const t0 = parseIsoMs(prev.sampledAt)
const t1 = parseIsoMs(cur.sampledAt)
if(Number.isFinite(Date.parse(cur.sampledAt))&&t1>=rangeStartMs&&t1<=rangeEndMs)lastAttemptAt=Math.max(lastAttemptAt??t1,t1)
const countersKnown = prev.countersKnown !== false && cur.countersKnown !== false
const sameIdentity = (prev.nativeCounterIdentity ?? null) === (cur.nativeCounterIdentity ?? null)
const countersKnown = prev.countersKnown !== false && cur.countersKnown !== false && cur.continuityKnown !== false && sameIdentity
const rxBps = countersKnown ? rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1) : null
const txBps = countersKnown ? rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1) : null
if (t1 >= rangeStartMs && t1 <= rangeEndMs) {
@@ -9,8 +9,8 @@ const database=process.env.MMAPP_OWN_SQL_DB
if(!database)console.log("traffic status 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-status181-"+Date.now()+"-",userId=prefix+"user";let serverId:number|undefined,cases=0,deny=false,flag:unknown=true,peerDisabled=false,peerVariant="valid",denyPeer=false
const http=createServer((req,res)=>{res.setHeader("Content-Type","application/json");if(deny || denyPeer && req.url?.includes("/wireguard/")){res.statusCode=401;return res.end('{"message":"Unauthorized"}')}res.end(JSON.stringify(req.url?.includes("/wireguard/")?(peerVariant==="shape"?{}:peerVariant==="duplicate"?[{interface:"wg1","public-key":"fixturekey",rx:"1000",tx:"1000",disabled:false},{interface:"wg1","public-key":"fixturekey",rx:"1000",tx:"1000",disabled:false}]:[{interface:"wg1","public-key":"fixturekey",rx:"1000",tx:"1000",disabled:peerDisabled},...(peerVariant==="partial"?[{interface:"wg1","public-key":"broken",rx:"invalid",tx:"1000",disabled:false}]:[])]):[{name:"ether1",running:flag,disabled:false,"rx-byte":"1000","tx-byte":"1000"}]))})
const prefix="own-status181-"+Date.now()+"-",userId=prefix+"user";let serverId:number|undefined,cases=0,deny=false,flag:unknown=true,peerDisabled=false,peerVariant="valid",denyPeer=false,ifaceId:unknown="*1",counterValue="1000",peerId="*2",peerCounter="1000"
const http=createServer((req,res)=>{res.setHeader("Content-Type","application/json");if(deny || denyPeer && req.url?.includes("/wireguard/")){res.statusCode=401;return res.end('{"message":"Unauthorized"}')}res.end(JSON.stringify(req.url?.includes("/wireguard/")?(peerVariant==="shape"?{}:peerVariant==="duplicate"?[{".id":peerId,interface:"wg1","public-key":"fixturekey",rx:"1000",tx:"1000",disabled:false},{".id":peerId,interface:"wg1","public-key":"fixturekey",rx:"1000",tx:"1000",disabled:false}]:[{".id":peerId,interface:"wg1","public-key":"fixturekey",rx:peerCounter,tx:peerCounter,disabled:peerDisabled},...(peerVariant==="partial"?[{".id":peerId,interface:"wg1","public-key":"broken",rx:"invalid",tx:"1000",disabled:false}]:[])]):[{".id":ifaceId,name:"ether1",running:flag,disabled:false,"rx-byte":counterValue,"tx-byte":counterValue}]))})
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 bindings=async()=>{const r=await app.inject({method:"GET",url:"/traffic/bound-interfaces?range=1h"});assert.equal(r.statusCode,200);return r.json().interfaces.filter((i:{userId:string})=>i.userId===userId)}
try{
@@ -39,6 +39,14 @@ else{
await pool.query("UPDATE traffic_samples SET sampled_at=$1 WHERE server_id=$2",[new Date(Date.now()-120_000).toISOString(),serverId]);
let gap=await collectTrafficOnce();assert.equal(gap.servers[0].ok,true);assert.equal(gap.servers[0].sumRxMbps,undefined);rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").observations.rxNow,false);
gap=await collectTrafficOnce();assert.equal(gap.servers[0].sumRxMbps,0);rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").observations.rxNow,true);cases++;
for(const change of["native-id","config","missing-id"]){
counterValue=String(Number(counterValue)+1000);
if(change==="native-id")ifaceId="*10";else if(change==="missing-id")ifaceId=undefined;else await pool.query("UPDATE servers SET updated_at=$1 WHERE id=$2",[new Date().toISOString(),serverId]);
const switched=await collectTrafficOnce();assert.equal(switched.servers[0].sumRxMbps,undefined);rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").observations.rxNow,false);assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").rxTotal,0);cases++;
if(change!=="missing-id"){const next=await collectTrafficOnce();assert.equal(next.servers[0].sumRxMbps,0);rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").observations.rxNow,true)}
}
ifaceId="*10";await collectTrafficOnce();await collectTrafficOnce();peerId="*20";peerCounter="2000";await collectTrafficOnce();rows=await bindings();let peer=rows.find((r:{interfaceName:string})=>r.interfaceName==="wg1");assert.equal(peer.observations.rxNow,false);assert.equal(peer.rxTotal,0);
await collectTrafficOnce();rows=await bindings();peer=rows.find((r:{interfaceName:string})=>r.interfaceName==="wg1");assert.equal(peer.observations.rxNow,true);assert.equal(peer.rxNow,0);cases++;
await pool.query("UPDATE servers SET enabled=false WHERE id=$1",[serverId]);rows=await bindings();assert(rows.every((r:{status:string})=>r.status==="unknown"));cases++
console.log(`traffic status owned SQL: ${cases} cases; first-wave native flags/peer config/invalid/error/legacy/stale/disabled passed`)
}finally{await app.close();await new Promise<void>(resolve=>http.close(()=>resolve()));await pool.query("DELETE FROM app_users WHERE id=$1",[userId]);if(serverId!==undefined){await pool.query("DELETE FROM traffic_samples WHERE server_id=$1",[serverId]);await pool.query("DELETE FROM servers WHERE id=$1",[serverId])}await closePool()}
+24 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;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-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2202,3 +2202,25 @@ Unit threshold/invalid interval/merge/collector guards, backend/application Type
[Source456/524](ux-ui/acceptance/traffic-gap-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-gap-compiled-2026-10-13.json) SHA256 parity. Backend production ux-gap185 [healthy/imageIds](ux-ui/acceptance/traffic-gap-runtime-2026-10-13.json), frontend ux-warning183 без исходных изменений/повторной сборки. Только backend.env tag, rollback backend ux-history184/frontend ux-warning183; secret env copy0, migration0. [Actual API](ux-ui/acceptance/traffic-gap-api-2026-10-13.json)/[production UI1440/320](ux-ui/acceptance/traffic-gap-deployed-2026-10-13.json) passed с actual live и backend receipt labels, no errors/overflow. Checker не отправлял новых native commands и не создавал отказ на CHR. Password/config/message changes0.
Следующий186 — provenance native interface/peer identity и конфигурации источника: positive counter difference по одному прежнему имени не доказывает непрерывность после пересоздания/смены устройства. Старую историю не переписывать по догадке. Общая12.5 и внешние зависимости остаются открыты. Локальный коммит без push.
## 12.5.186 — подтверждение идентичности пары счётчиков (2026-10-13)
Имя интерфейса больше не является единственным основанием новой counter pair. Nullable native_counter_identity хранит нормализованные source.updatedAt и native RouterOS.id (*HEX). Producer подтверждает continuity только когда identity непустая и совпадает с предыдущей строкой того же interface/peer key. Новый native ID, конфигурация источника или missing/invalid ID дают первую pair unknown; следующий подтверждённый poll восстанавливает измерение. Для WG используются peer.id и существующий public-key/iface series key, не ID самого интерфейса.
Биты16/32 различают confirmed continuity и сам факт проверки новым producer; running/disabled/counter/status markers не меняют смысла. Builder исключает неподтверждённую или несовпадающую pair из новых rates/bytes, сохраняя остальные подтверждённые исторические пары. Первый корректный counter остаётся baseline для следующей pair, даже если его собственная предыдущая continuity неизвестна. Legacy rows не переписываются: absent new marker/native identity оставляет прежнюю явно ограниченную историю. Это не доказательство непрерывности legacy, повторного использования того же native ID после перезагрузки или физической доступности.
| Изменение | Схема результата | Проверка |
| --- | --- | --- |
| Same name, новый native ID | old counter → unknown boundary → новый baseline → confirmed0 | own SQL interface+peer |
| Config version изменилась | не переносить counter pair между источниками → normal next poll | own SQL |
| Missing native ID | новые pair unknown, без guessed zero/bytes | own SQL/unit |
Миграция0012 добавляет nullable TEXT к partitioned traffic_samples, включая существующие partitions; нет массового UPDATE/переписывания flags. Downgrade старого reader может игнорировать новый столбец, migration removal не выполнялась. Реальный backup/restore с новой колонкой — следующий187, пока не принят данным блоком.
Unit identity/config/legacy-marker/pair guards, backend/application TypeScript и diff checks passed. [Own PG SQL](ux-ui/acceptance/traffic-identity-sql-2026-10-13.json):62 cases9counter+14history+10TCP SSE+10volume+19status/37613ms, no skip.4 новые cases: interface ID replacement/config version/missing ID/peer ID replacement с positive counters, unknown boundary, no extra bytes и последующим measured0. Fixtures явно объявляют native IDs; VM actual-collector unit получил настоящий identity/peer parser dependencies вместо отсутствовавших globals. Первая попытка остановилась до SQL scenarios на прежнем VM fixture; временная база/role удалены. Последний namespace mm_accept_166_1791828561251/role/container удалён, absence verified.
[Source458/524](ux-ui/acceptance/traffic-identity-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-identity-compiled-2026-10-13.json) SHA256 parity. [Backend ux-identity186](ux-ui/acceptance/traffic-identity-runtime-2026-10-13.json) healthy/imageIds; frontend ux-warning183 без изменений/новой сборки. Only backend.env tag, rollback backend ux-gap185/frontend ux-warning183; additive migration остаётся. Secret env copy0.
[Native GET](ux-ui/acceptance/traffic-identity-native-2026-10-13.json): оба CHR доступны, interface native IDs/flags/counters валидны;0012 applied. Peers фактически0 — positive peer replacement доказан изолированным fixture, не реальным VPN-туннелем. DB read-only/RouterOS config writes0/password changes0/messages0. [Initial API](ux-ui/acceptance/traffic-identity-api-2026-10-13.json) ожидаемо unknown при первом baseline после нового writer; [штатное восстановление](ux-ui/acceptance/traffic-identity-recovery-2026-10-13.json) подтвердило current/rxKnown/txKnown обоих CHR с30s interval без manual collector invocation. [Production UI1440/320](ux-ui/acceptance/traffic-identity-deployed-2026-10-13.json) actual live/receipt labels/no errors/overflow. Native SSE не изменён; предшествующие6E2E185 UI guard checks не пересчитываются как новые186.
Следующий187 — own-schema archive restore для nullable native counter identity/continuity flags и старого schema archive с повторным bootstrap migration. Потом отдельная приёмка многопроцессной координации/масштаба/внешних доступов.12.5 открыта. Локальный коммит без push.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;12.5 открыта.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-12
Актуальное продолжение 2026-10-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;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-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+23 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;12.5 открыта.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,28 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
## 12.5.186 — подтверждение идентичности пары счётчиков (2026-10-13)
Имя интерфейса больше не является единственным основанием новой counter pair. Nullable native_counter_identity хранит нормализованные source.updatedAt и native RouterOS.id (*HEX). Producer подтверждает continuity только когда identity непустая и совпадает с предыдущей строкой того же interface/peer key. Новый native ID, конфигурация источника или missing/invalid ID дают первую pair unknown; следующий подтверждённый poll восстанавливает измерение. Для WG используются peer.id и существующий public-key/iface series key, не ID самого интерфейса.
Биты16/32 различают confirmed continuity и сам факт проверки новым producer; running/disabled/counter/status markers не меняют смысла. Builder исключает неподтверждённую или несовпадающую pair из новых rates/bytes, сохраняя остальные подтверждённые исторические пары. Первый корректный counter остаётся baseline для следующей pair, даже если его собственная предыдущая continuity неизвестна. Legacy rows не переписываются: absent new marker/native identity оставляет прежнюю явно ограниченную историю. Это не доказательство непрерывности legacy, повторного использования того же native ID после перезагрузки или физической доступности.
| Изменение | Схема результата | Проверка |
| --- | --- | --- |
| Same name, новый native ID | old counter → unknown boundary → новый baseline → confirmed0 | own SQL interface+peer |
| Config version изменилась | не переносить counter pair между источниками → normal next poll | own SQL |
| Missing native ID | новые pair unknown, без guessed zero/bytes | own SQL/unit |
Миграция0012 добавляет nullable TEXT к partitioned traffic_samples, включая существующие partitions; нет массового UPDATE/переписывания flags. Downgrade старого reader может игнорировать новый столбец, migration removal не выполнялась. Реальный backup/restore с новой колонкой — следующий187, пока не принят данным блоком.
Unit identity/config/legacy-marker/pair guards, backend/application TypeScript и diff checks passed. [Own PG SQL](ux-ui/acceptance/traffic-identity-sql-2026-10-13.json):62 cases9counter+14history+10TCP SSE+10volume+19status/37613ms, no skip.4 новые cases: interface ID replacement/config version/missing ID/peer ID replacement с positive counters, unknown boundary, no extra bytes и последующим measured0. Fixtures явно объявляют native IDs; VM actual-collector unit получил настоящий identity/peer parser dependencies вместо отсутствовавших globals. Первая попытка остановилась до SQL scenarios на прежнем VM fixture; временная база/role удалены. Последний namespace mm_accept_166_1791828561251/role/container удалён, absence verified.
[Source458/524](ux-ui/acceptance/traffic-identity-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-identity-compiled-2026-10-13.json) SHA256 parity. [Backend ux-identity186](ux-ui/acceptance/traffic-identity-runtime-2026-10-13.json) healthy/imageIds; frontend ux-warning183 без изменений/новой сборки. Only backend.env tag, rollback backend ux-gap185/frontend ux-warning183; additive migration остаётся. Secret env copy0.
[Native GET](ux-ui/acceptance/traffic-identity-native-2026-10-13.json): оба CHR доступны, interface native IDs/flags/counters валидны;0012 applied. Peers фактически0 — positive peer replacement доказан изолированным fixture, не реальным VPN-туннелем. DB read-only/RouterOS config writes0/password changes0/messages0. [Initial API](ux-ui/acceptance/traffic-identity-api-2026-10-13.json) ожидаемо unknown при первом baseline после нового writer; [штатное восстановление](ux-ui/acceptance/traffic-identity-recovery-2026-10-13.json) подтвердило current/rxKnown/txKnown обоих CHR с30s interval без manual collector invocation. [Production UI1440/320](ux-ui/acceptance/traffic-identity-deployed-2026-10-13.json) actual live/receipt labels/no errors/overflow. Native SSE не изменён; предшествующие6E2E185 UI guard checks не пересчитываются как новые186.
Следующий187 — own-schema archive restore для nullable native counter identity/continuity flags и старого schema archive с повторным bootstrap migration. Потом отдельная приёмка многопроцессной координации/масштаба/внешних доступов.12.5 открыта. Локальный коммит без push.
## 12.5.185 — доверие текущей скорости после длительного пропуска (2026-10-13)
Завершает baseline185 от2026-10-12. Builder сообщает measurementIntervalMs последней пары counter samples; для агрегата используется максимальный интервал всех составляющих, отсутствие interval в одной из новых составляющих даёт null. applyTrafficFreshness разрешает текущую counter-derived скорость только при положительном конечном interval≤3×max(5s,configured period). Это тот же запас3периода, что у TTL. При периоде30s граница90s включительно; более длинный или неизвестный интервал даёт unknown и понятную причину. Историческое среднее и подтверждённые counter bytes сохраняются.
+24 -2
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-12
Это актуальная матрица после 12.5.185. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Это актуальная матрица после 12.5.186. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-gap185`, frontend `ux-warning183`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-identity186`, frontend `ux-warning183`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
| Раздел | Подтверждено | Осталось / условие завершения |
| --- | --- | --- |
@@ -1117,3 +1117,25 @@ Unit threshold/invalid interval/merge/collector guards, backend/application Type
[Source456/524](ux-ui/acceptance/traffic-gap-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-gap-compiled-2026-10-13.json) SHA256 parity. Backend production ux-gap185 [healthy/imageIds](ux-ui/acceptance/traffic-gap-runtime-2026-10-13.json), frontend ux-warning183 без исходных изменений/повторной сборки. Только backend.env tag, rollback backend ux-history184/frontend ux-warning183; secret env copy0, migration0. [Actual API](ux-ui/acceptance/traffic-gap-api-2026-10-13.json)/[production UI1440/320](ux-ui/acceptance/traffic-gap-deployed-2026-10-13.json) passed с actual live и backend receipt labels, no errors/overflow. Checker не отправлял новых native commands и не создавал отказ на CHR. Password/config/message changes0.
Следующий186 — provenance native interface/peer identity и конфигурации источника: positive counter difference по одному прежнему имени не доказывает непрерывность после пересоздания/смены устройства. Старую историю не переписывать по догадке. Общая12.5 и внешние зависимости остаются открыты. Локальный коммит без push.
## 12.5.186 — подтверждение идентичности пары счётчиков (2026-10-13)
Имя интерфейса больше не является единственным основанием новой counter pair. Nullable native_counter_identity хранит нормализованные source.updatedAt и native RouterOS.id (*HEX). Producer подтверждает continuity только когда identity непустая и совпадает с предыдущей строкой того же interface/peer key. Новый native ID, конфигурация источника или missing/invalid ID дают первую pair unknown; следующий подтверждённый poll восстанавливает измерение. Для WG используются peer.id и существующий public-key/iface series key, не ID самого интерфейса.
Биты16/32 различают confirmed continuity и сам факт проверки новым producer; running/disabled/counter/status markers не меняют смысла. Builder исключает неподтверждённую или несовпадающую pair из новых rates/bytes, сохраняя остальные подтверждённые исторические пары. Первый корректный counter остаётся baseline для следующей pair, даже если его собственная предыдущая continuity неизвестна. Legacy rows не переписываются: absent new marker/native identity оставляет прежнюю явно ограниченную историю. Это не доказательство непрерывности legacy, повторного использования того же native ID после перезагрузки или физической доступности.
| Изменение | Схема результата | Проверка |
| --- | --- | --- |
| Same name, новый native ID | old counter → unknown boundary → новый baseline → confirmed0 | own SQL interface+peer |
| Config version изменилась | не переносить counter pair между источниками → normal next poll | own SQL |
| Missing native ID | новые pair unknown, без guessed zero/bytes | own SQL/unit |
Миграция0012 добавляет nullable TEXT к partitioned traffic_samples, включая существующие partitions; нет массового UPDATE/переписывания flags. Downgrade старого reader может игнорировать новый столбец, migration removal не выполнялась. Реальный backup/restore с новой колонкой — следующий187, пока не принят данным блоком.
Unit identity/config/legacy-marker/pair guards, backend/application TypeScript и diff checks passed. [Own PG SQL](ux-ui/acceptance/traffic-identity-sql-2026-10-13.json):62 cases9counter+14history+10TCP SSE+10volume+19status/37613ms, no skip.4 новые cases: interface ID replacement/config version/missing ID/peer ID replacement с positive counters, unknown boundary, no extra bytes и последующим measured0. Fixtures явно объявляют native IDs; VM actual-collector unit получил настоящий identity/peer parser dependencies вместо отсутствовавших globals. Первая попытка остановилась до SQL scenarios на прежнем VM fixture; временная база/role удалены. Последний namespace mm_accept_166_1791828561251/role/container удалён, absence verified.
[Source458/524](ux-ui/acceptance/traffic-identity-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-identity-compiled-2026-10-13.json) SHA256 parity. [Backend ux-identity186](ux-ui/acceptance/traffic-identity-runtime-2026-10-13.json) healthy/imageIds; frontend ux-warning183 без изменений/новой сборки. Only backend.env tag, rollback backend ux-gap185/frontend ux-warning183; additive migration остаётся. Secret env copy0.
[Native GET](ux-ui/acceptance/traffic-identity-native-2026-10-13.json): оба CHR доступны, interface native IDs/flags/counters валидны;0012 applied. Peers фактически0 — positive peer replacement доказан изолированным fixture, не реальным VPN-туннелем. DB read-only/RouterOS config writes0/password changes0/messages0. [Initial API](ux-ui/acceptance/traffic-identity-api-2026-10-13.json) ожидаемо unknown при первом baseline после нового writer; [штатное восстановление](ux-ui/acceptance/traffic-identity-recovery-2026-10-13.json) подтвердило current/rxKnown/txKnown обоих CHR с30s interval без manual collector invocation. [Production UI1440/320](ux-ui/acceptance/traffic-identity-deployed-2026-10-13.json) actual live/receipt labels/no errors/overflow. Native SSE не изменён; предшествующие6E2E185 UI guard checks не пересчитываются как новые186.
Следующий187 — own-schema archive restore для nullable native counter identity/continuity flags и старого schema archive с повторным bootstrap migration. Потом отдельная приёмка многопроцессной координации/масштаба/внешних доступов.12.5 открыта. Локальный коммит без push.
+23 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;12.5 открыта.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3507,3 +3507,25 @@ Unit threshold/invalid interval/merge/collector guards, backend/application Type
[Source456/524](ux-ui/acceptance/traffic-gap-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-gap-compiled-2026-10-13.json) SHA256 parity. Backend production ux-gap185 [healthy/imageIds](ux-ui/acceptance/traffic-gap-runtime-2026-10-13.json), frontend ux-warning183 без исходных изменений/повторной сборки. Только backend.env tag, rollback backend ux-history184/frontend ux-warning183; secret env copy0, migration0. [Actual API](ux-ui/acceptance/traffic-gap-api-2026-10-13.json)/[production UI1440/320](ux-ui/acceptance/traffic-gap-deployed-2026-10-13.json) passed с actual live и backend receipt labels, no errors/overflow. Checker не отправлял новых native commands и не создавал отказ на CHR. Password/config/message changes0.
Следующий186 — provenance native interface/peer identity и конфигурации источника: positive counter difference по одному прежнему имени не доказывает непрерывность после пересоздания/смены устройства. Старую историю не переписывать по догадке. Общая12.5 и внешние зависимости остаются открыты. Локальный коммит без push.
## 12.5.186 — подтверждение идентичности пары счётчиков (2026-10-13)
Имя интерфейса больше не является единственным основанием новой counter pair. Nullable native_counter_identity хранит нормализованные source.updatedAt и native RouterOS.id (*HEX). Producer подтверждает continuity только когда identity непустая и совпадает с предыдущей строкой того же interface/peer key. Новый native ID, конфигурация источника или missing/invalid ID дают первую pair unknown; следующий подтверждённый poll восстанавливает измерение. Для WG используются peer.id и существующий public-key/iface series key, не ID самого интерфейса.
Биты16/32 различают confirmed continuity и сам факт проверки новым producer; running/disabled/counter/status markers не меняют смысла. Builder исключает неподтверждённую или несовпадающую pair из новых rates/bytes, сохраняя остальные подтверждённые исторические пары. Первый корректный counter остаётся baseline для следующей pair, даже если его собственная предыдущая continuity неизвестна. Legacy rows не переписываются: absent new marker/native identity оставляет прежнюю явно ограниченную историю. Это не доказательство непрерывности legacy, повторного использования того же native ID после перезагрузки или физической доступности.
| Изменение | Схема результата | Проверка |
| --- | --- | --- |
| Same name, новый native ID | old counter → unknown boundary → новый baseline → confirmed0 | own SQL interface+peer |
| Config version изменилась | не переносить counter pair между источниками → normal next poll | own SQL |
| Missing native ID | новые pair unknown, без guessed zero/bytes | own SQL/unit |
Миграция0012 добавляет nullable TEXT к partitioned traffic_samples, включая существующие partitions; нет массового UPDATE/переписывания flags. Downgrade старого reader может игнорировать новый столбец, migration removal не выполнялась. Реальный backup/restore с новой колонкой — следующий187, пока не принят данным блоком.
Unit identity/config/legacy-marker/pair guards, backend/application TypeScript и diff checks passed. [Own PG SQL](ux-ui/acceptance/traffic-identity-sql-2026-10-13.json):62 cases9counter+14history+10TCP SSE+10volume+19status/37613ms, no skip.4 новые cases: interface ID replacement/config version/missing ID/peer ID replacement с positive counters, unknown boundary, no extra bytes и последующим measured0. Fixtures явно объявляют native IDs; VM actual-collector unit получил настоящий identity/peer parser dependencies вместо отсутствовавших globals. Первая попытка остановилась до SQL scenarios на прежнем VM fixture; временная база/role удалены. Последний namespace mm_accept_166_1791828561251/role/container удалён, absence verified.
[Source458/524](ux-ui/acceptance/traffic-identity-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-identity-compiled-2026-10-13.json) SHA256 parity. [Backend ux-identity186](ux-ui/acceptance/traffic-identity-runtime-2026-10-13.json) healthy/imageIds; frontend ux-warning183 без изменений/новой сборки. Only backend.env tag, rollback backend ux-gap185/frontend ux-warning183; additive migration остаётся. Secret env copy0.
[Native GET](ux-ui/acceptance/traffic-identity-native-2026-10-13.json): оба CHR доступны, interface native IDs/flags/counters валидны;0012 applied. Peers фактически0 — positive peer replacement доказан изолированным fixture, не реальным VPN-туннелем. DB read-only/RouterOS config writes0/password changes0/messages0. [Initial API](ux-ui/acceptance/traffic-identity-api-2026-10-13.json) ожидаемо unknown при первом baseline после нового writer; [штатное восстановление](ux-ui/acceptance/traffic-identity-recovery-2026-10-13.json) подтвердило current/rxKnown/txKnown обоих CHR с30s interval без manual collector invocation. [Production UI1440/320](ux-ui/acceptance/traffic-identity-deployed-2026-10-13.json) actual live/receipt labels/no errors/overflow. Native SSE не изменён; предшествующие6E2E185 UI guard checks не пересчитываются как новые186.
Следующий187 — own-schema archive restore для nullable native counter identity/continuity flags и старого schema archive с повторным bootstrap migration. Потом отдельная приёмка многопроцессной координации/масштаба/внешних доступов.12.5 открыта. Локальный коммит без push.
+25 -3
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10
Сервер161.104.46.222: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;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-gap185 и mmapp-test/frontend:ux-warning183; запущены |
| Образы приложения | mmapp-test/backend:ux-identity186 и mmapp-test/frontend:ux-warning183; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -708,7 +708,7 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-13: завершён12.5.185 — current counter rate ограничен3периодами; long gap→unknown без потери history/known bytes; collector snapshot также не выдаёт long average как current. OwnSQL58/37008ms/6E2E14.7s/types/build/ESLint/source456+524/1040compiled/API/browser2 passed. Backend ux-gap185 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий186 — native identity/config continuity;12.5 открыта.
Актуальное продолжение 2026-10-13: завершён12.5.186 — native counter identity+source config, declared continuity flags, migration0012 без переписывания legacy. OwnSQL62/37613ms/types/build/source458+524/1040compiled/native2/recovery2/browser2 passed. Backend ux-identity186 healthy/frontend ux-warning183; ownDB/role/container удалены. Следующий187 — совместимость backup/restore после nullable-column migration;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -1933,3 +1933,25 @@ Unit threshold/invalid interval/merge/collector guards, backend/application Type
[Source456/524](ux-ui/acceptance/traffic-gap-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-gap-compiled-2026-10-13.json) SHA256 parity. Backend production ux-gap185 [healthy/imageIds](ux-ui/acceptance/traffic-gap-runtime-2026-10-13.json), frontend ux-warning183 без исходных изменений/повторной сборки. Только backend.env tag, rollback backend ux-history184/frontend ux-warning183; secret env copy0, migration0. [Actual API](ux-ui/acceptance/traffic-gap-api-2026-10-13.json)/[production UI1440/320](ux-ui/acceptance/traffic-gap-deployed-2026-10-13.json) passed с actual live и backend receipt labels, no errors/overflow. Checker не отправлял новых native commands и не создавал отказ на CHR. Password/config/message changes0.
Следующий186 — provenance native interface/peer identity и конфигурации источника: positive counter difference по одному прежнему имени не доказывает непрерывность после пересоздания/смены устройства. Старую историю не переписывать по догадке. Общая12.5 и внешние зависимости остаются открыты. Локальный коммит без push.
## 12.5.186 — подтверждение идентичности пары счётчиков (2026-10-13)
Имя интерфейса больше не является единственным основанием новой counter pair. Nullable native_counter_identity хранит нормализованные source.updatedAt и native RouterOS.id (*HEX). Producer подтверждает continuity только когда identity непустая и совпадает с предыдущей строкой того же interface/peer key. Новый native ID, конфигурация источника или missing/invalid ID дают первую pair unknown; следующий подтверждённый poll восстанавливает измерение. Для WG используются peer.id и существующий public-key/iface series key, не ID самого интерфейса.
Биты16/32 различают confirmed continuity и сам факт проверки новым producer; running/disabled/counter/status markers не меняют смысла. Builder исключает неподтверждённую или несовпадающую pair из новых rates/bytes, сохраняя остальные подтверждённые исторические пары. Первый корректный counter остаётся baseline для следующей pair, даже если его собственная предыдущая continuity неизвестна. Legacy rows не переписываются: absent new marker/native identity оставляет прежнюю явно ограниченную историю. Это не доказательство непрерывности legacy, повторного использования того же native ID после перезагрузки или физической доступности.
| Изменение | Схема результата | Проверка |
| --- | --- | --- |
| Same name, новый native ID | old counter → unknown boundary → новый baseline → confirmed0 | own SQL interface+peer |
| Config version изменилась | не переносить counter pair между источниками → normal next poll | own SQL |
| Missing native ID | новые pair unknown, без guessed zero/bytes | own SQL/unit |
Миграция0012 добавляет nullable TEXT к partitioned traffic_samples, включая существующие partitions; нет массового UPDATE/переписывания flags. Downgrade старого reader может игнорировать новый столбец, migration removal не выполнялась. Реальный backup/restore с новой колонкой — следующий187, пока не принят данным блоком.
Unit identity/config/legacy-marker/pair guards, backend/application TypeScript и diff checks passed. [Own PG SQL](ux-ui/acceptance/traffic-identity-sql-2026-10-13.json):62 cases9counter+14history+10TCP SSE+10volume+19status/37613ms, no skip.4 новые cases: interface ID replacement/config version/missing ID/peer ID replacement с positive counters, unknown boundary, no extra bytes и последующим measured0. Fixtures явно объявляют native IDs; VM actual-collector unit получил настоящий identity/peer parser dependencies вместо отсутствовавших globals. Первая попытка остановилась до SQL scenarios на прежнем VM fixture; временная база/role удалены. Последний namespace mm_accept_166_1791828561251/role/container удалён, absence verified.
[Source458/524](ux-ui/acceptance/traffic-identity-source-2026-10-13.json)/[compiled1040](ux-ui/acceptance/traffic-identity-compiled-2026-10-13.json) SHA256 parity. [Backend ux-identity186](ux-ui/acceptance/traffic-identity-runtime-2026-10-13.json) healthy/imageIds; frontend ux-warning183 без изменений/новой сборки. Only backend.env tag, rollback backend ux-gap185/frontend ux-warning183; additive migration остаётся. Secret env copy0.
[Native GET](ux-ui/acceptance/traffic-identity-native-2026-10-13.json): оба CHR доступны, interface native IDs/flags/counters валидны;0012 applied. Peers фактически0 — positive peer replacement доказан изолированным fixture, не реальным VPN-туннелем. DB read-only/RouterOS config writes0/password changes0/messages0. [Initial API](ux-ui/acceptance/traffic-identity-api-2026-10-13.json) ожидаемо unknown при первом baseline после нового writer; [штатное восстановление](ux-ui/acceptance/traffic-identity-recovery-2026-10-13.json) подтвердило current/rxKnown/txKnown обоих CHR с30s interval без manual collector invocation. [Production UI1440/320](ux-ui/acceptance/traffic-identity-deployed-2026-10-13.json) actual live/receipt labels/no errors/overflow. Native SSE не изменён; предшествующие6E2E185 UI guard checks не пересчитываются как новые186.
Следующий187 — own-schema archive restore для nullable native counter identity/continuity flags и старого schema archive с повторным bootstrap migration. Потом отдельная приёмка многопроцессной координации/масштаба/внешних доступов.12.5 открыта. Локальный коммит без push.
@@ -0,0 +1,26 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"version": "1.0.0-test.identity186",
"passed": true,
"method": "Read-only actual application volume API; no new native RouterOS commands; exact edge values covered separately by owned SQL",
"cases": [
{
"sourceId": "1",
"volumeUnit": "GiB",
"rxTotalGiB": 0.013450461439788342,
"txTotalGiB": 0.0018185554072260857,
"currentState": "unknown"
},
{
"sourceId": "2",
"volumeUnit": "GiB",
"rxTotalGiB": 0.002372242510318756,
"txTotalGiB": 0.002620793879032135,
"currentState": "unknown"
}
],
"newRouterOSCommands": 0,
"passwordChanges": 0,
"messages": 0
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"runtime": {
"backend": "ux-identity186",
"frontend": "ux-warning183"
},
"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,37 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"version": "1.0.0-test.identity186",
"readOnlyDatabase": true,
"cases": [
{
"sourceId": 1,
"target": "194.226.121.150",
"identityRead": true,
"interfaceRead": true,
"interfaceCount": 2,
"countersValid": true,
"nativeBooleanFlagsValidated": true,
"peerSliceValidated": true,
"peerCount": 0,
"nativeCounterIdsValidated": true
},
{
"sourceId": 2,
"target": "104.165.244.7",
"identityRead": true,
"interfaceRead": true,
"interfaceCount": 2,
"countersValid": true,
"nativeBooleanFlagsValidated": true,
"peerSliceValidated": true,
"peerCount": 0,
"nativeCounterIdsValidated": true
}
],
"dbWrites": 0,
"routerConfigWrites": 0,
"messages": 0,
"migrationApplied": true,
"passed": true
}
@@ -0,0 +1,22 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"method": "Read-only actual API after automatic polls; no manual collector invocation",
"cases": [
{
"sourceId": "1",
"state": "current",
"rxKnown": true,
"txKnown": true,
"measurementIntervalMs": 30000
},
{
"sourceId": "2",
"state": "current",
"rxKnown": true,
"txKnown": true,
"measurementIntervalMs": 30000
}
],
"passed": true
}
@@ -0,0 +1,25 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"secretEnvCopied": false,
"rollbackTags": {
"MMAPP_IMAGE_TAG": "ux-warning183",
"BACKEND_IMAGE_TAG": "ux-gap185"
},
"services": {
"backend": {
"image": "mmapp-test/backend:ux-identity186",
"imageId": "sha256:0856c19bdcba4f8514b582f610073fc24249fbafee4972b2ea0f29f6b00d3fd5",
"state": "running",
"health": "healthy",
"matchedVerifiedImage": true
},
"frontend": {
"image": "mmapp-test/frontend:ux-warning183",
"imageId": "sha256:4cf0549a212bfdc75c0107f3725686a9a9746a46ed6493ac73f608a0a335a586",
"state": "running",
"health": null,
"matchedVerifiedImage": true
}
}
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"matched": {
"backend": 458,
"frontend": 524
},
"different": [],
"missing": []
}
@@ -0,0 +1,31 @@
{
"date": "2026-10-13",
"block": "12.5.186",
"passed": true,
"profiles": [
{
"date": "2026-10-13",
"block": "12.5.186",
"database": "mm_accept_166_1791828561251",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:traffic-rate",
"passed": true,
"durationMs": 37613,
"ownedTrafficSqlCases": 9,
"ownedHistorySqlCases": 14,
"ownedLiveSqlCases": 10,
"ownedVolumeSqlCases": 10,
"ownedStatusSqlCases": 19,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
}
]
}