fix(traffic): подтверждать свежесть статусов привязанных интерфейсов

This commit is contained in:
Denozordec committed 2026-10-12 22:35:17 +07:00
1 parent 2b3d583e3b
commit b4d0733cfa
27 files changed
+1463 -36

No files matched your search

+8 -4
View File
@@ -3,6 +3,7 @@
import { useState, useMemo, useEffect, type ReactNode } from "react" import { useState, useMemo, useEffect, type ReactNode } from "react"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert" import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { projectTrafficObservations, type TrafficObservationMeta } from "@/lib/traffic-observations" import { projectTrafficObservations, type TrafficObservationMeta } from "@/lib/traffic-observations"
import { projectTrafficBindingStatus } from "@/lib/traffic-binding-status"
import { TrafficObservationStatus } from "@/components/traffic/traffic-observation-status" import { TrafficObservationStatus } from "@/components/traffic/traffic-observation-status"
import { TrafficDataTime } from "@/components/traffic/traffic-data-time" import { TrafficDataTime } from "@/components/traffic/traffic-data-time"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
@@ -86,6 +87,8 @@ function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): st
// ─── data model ─────────────────────────────────────────────────────────────── // ─── data model ───────────────────────────────────────────────────────────────
interface BoundIfaceTraffic { interface BoundIfaceTraffic {
statusObservedAt?: string|null
statusExpiresAt?: string|null
volumeUnit?: "GiB" volumeUnit?: "GiB"
observations?: TrafficObservationMeta observations?: TrafficObservationMeta
id: string id: string
@@ -110,7 +113,7 @@ interface BoundIfaceTraffic {
txTotal: number | null txTotal: number | null
rxSeries: (number|null)[] rxSeries: (number|null)[]
txSeries: (number|null)[] txSeries: (number|null)[]
status: "online" | "offline" status: "online" | "offline" | "unknown"
} }
interface ServerTraffic { interface ServerTraffic {
@@ -508,7 +511,7 @@ function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium tabular-nums", "shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium tabular-nums",
c.status === "online" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground", c.status === "online" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
)}> )}>
{c.status === "online" ? "online" : "offline"} {c.status === "online" ? "Работает" : c.status === "unknown" ? "Нет данных" : "Неактивен"}
</span> </span>
</div> </div>
) )
@@ -767,6 +770,7 @@ const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
...c, ...c,
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType, interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
...projectTrafficObservations(c), ...projectTrafficObservations(c),
status: projectTrafficBindingStatus(c.status,c.statusObservedAt,c.statusExpiresAt),
}) })
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => { const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
@@ -965,7 +969,7 @@ export default function TrafficPage() {
const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers.map(s => ({ ...s, ...projectTrafficObservations(s, trafficClock) })) : []) : serverTraffic, [isLive, trafficReady, liveServers, trafficClock]) const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers.map(s => ({ ...s, ...projectTrafficObservations(s, trafficClock) })) : []) : serverTraffic, [isLive, trafficReady, liveServers, trafficClock])
const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers.map(u => ({ ...u, ...projectTrafficObservations(u, trafficClock), interfaces: u.interfaces.map(toLiveBound) })) : []) : userTraffic, [isLive, trafficReady, liveUsers, trafficClock]) const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers.map(u => ({ ...u, ...projectTrafficObservations(u, trafficClock), interfaces: u.interfaces.map(toLiveBound) })) : []) : userTraffic, [isLive, trafficReady, liveUsers, trafficClock])
const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces.map(c => ({ ...c, ...projectTrafficObservations(c, trafficClock) })) : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces, trafficClock]) const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces.map(c => ({ ...c, ...projectTrafficObservations(c, trafficClock), status: projectTrafficBindingStatus(c.status,c.statusObservedAt,c.statusExpiresAt,trafficClock) })) : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces, trafficClock])
const visibleIfaces = useMemo( const visibleIfaces = useMemo(
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces, () => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
[serverIfaces, hideDisabledIfaces], [serverIfaces, hideDisabledIfaces],
@@ -1099,7 +1103,7 @@ export default function TrafficPage() {
const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic]) const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = isLive ? (currentDetail?.server ? { ...currentDetail.server, ...projectTrafficObservations(currentDetail.server, trafficClock) } : null) : selServer const detailServer = isLive ? (currentDetail?.server ? { ...currentDetail.server, ...projectTrafficObservations(currentDetail.server, trafficClock), boundIfaces: currentDetail.server.boundIfaces.map(toLiveBound) } : null) : selServer
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic]) const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
const selIface = useMemo(() => activeBoundIfaces.find(c => c.id === selectedId) ?? activeBoundIfaces[0], [selectedId, activeBoundIfaces]) const selIface = useMemo(() => activeBoundIfaces.find(c => c.id === selectedId) ?? activeBoundIfaces[0], [selectedId, activeBoundIfaces])
+1 -1
View File
@@ -19,7 +19,7 @@
"test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts", "test:exec": "tsx src/services/mikrotik-logs.test.ts && tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts && npx tsx src/services/wireguard-ipv6.test.ts && npx tsx --test src/services/wireguard-import.test.ts src/services/wireguard-import-apply.test.ts src/services/wireguard-import-revision.test.ts src/services/wireguard-delete.test.ts && npx tsx src/routes/wireguard-import.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts && tsx src/services/traffic-observation.test.ts && tsx src/services/traffic-freshness.test.ts && tsx src/services/traffic-live.test.ts && tsx src/services/traffic-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts && tsx src/services/traffic-live-sql.test.ts && tsx src/services/traffic-volume-sql.test.ts", "test:traffic-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",
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", "test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts", "test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
+5 -4
View File
@@ -12,10 +12,11 @@ assert.equal(encodeTrafficFlags(true, true), 3)
assert.equal(encodeTrafficFlags(false, false), 0) assert.equal(encodeTrafficFlags(false, false), 0)
assert.equal(trafficFlagRunning(1), true) assert.equal(trafficFlagRunning(1), true)
assert.equal(trafficFlagDisabled(1), false) assert.equal(trafficFlagDisabled(1), false)
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false, countersKnown: false }) assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false, countersKnown: false, statusKnown:false })
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false, countersKnown: false }) assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false, countersKnown: false, statusKnown:false })
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false, countersKnown: false }) assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false, countersKnown: false, statusKnown:false })
assert.equal(encodeTrafficFlags(true,false,true),5) assert.equal(encodeTrafficFlags(true,false,true),5)
assert.deepEqual(decodeTrafficSampleFlags(5), {running:true,disabled:false,countersKnown:true}) assert.deepEqual(decodeTrafficSampleFlags(5), {running:true,disabled:false,countersKnown:true,statusKnown:false})
assert.deepEqual(decodeTrafficSampleFlags(encodeTrafficFlags(true,false,true,true)),{running:true,disabled:false,countersKnown:true,statusKnown:true})
console.log("traffic-flags.test.ts: ok") console.log("traffic-flags.test.ts: ok")
+5 -1
View File
@@ -1,10 +1,12 @@
export const TRAFFIC_FLAG_RUNNING = 1 export const TRAFFIC_FLAG_RUNNING = 1
export const TRAFFIC_FLAG_DISABLED = 2 export const TRAFFIC_FLAG_DISABLED = 2
export const TRAFFIC_FLAG_COUNTERS_KNOWN = 4 export const TRAFFIC_FLAG_COUNTERS_KNOWN = 4
export const TRAFFIC_FLAG_STATUS_KNOWN = 8
export function encodeTrafficFlags(running: boolean, disabled: boolean, countersKnown = false): number { export function encodeTrafficFlags(running: boolean, disabled: boolean, countersKnown = false, statusKnown = false): number {
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0) return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
| (countersKnown ? TRAFFIC_FLAG_COUNTERS_KNOWN : 0) | (countersKnown ? TRAFFIC_FLAG_COUNTERS_KNOWN : 0)
| (statusKnown ? TRAFFIC_FLAG_STATUS_KNOWN : 0)
} }
export function trafficFlagRunning(flags: number | null | undefined): boolean { export function trafficFlagRunning(flags: number | null | undefined): boolean {
@@ -19,10 +21,12 @@ export function decodeTrafficSampleFlags(flags: number | null | undefined): {
running: boolean running: boolean
disabled: boolean disabled: boolean
countersKnown: boolean countersKnown: boolean
statusKnown: boolean
} { } {
return { return {
running: trafficFlagRunning(flags), running: trafficFlagRunning(flags),
disabled: trafficFlagDisabled(flags), disabled: trafficFlagDisabled(flags),
countersKnown: ((Number(flags) || 0) & TRAFFIC_FLAG_COUNTERS_KNOWN) !== 0, countersKnown: ((Number(flags) || 0) & TRAFFIC_FLAG_COUNTERS_KNOWN) !== 0,
statusKnown: ((Number(flags) || 0) & TRAFFIC_FLAG_STATUS_KNOWN) !== 0,
} }
} }
@@ -0,0 +1,18 @@
import assert from "node:assert/strict"
import { trafficBindingStatus } from "./traffic-freshness.js"
import { parseTrafficFlag } from "./traffic-observation.js"
const now=Date.parse("2026-10-12T12:00:00Z"),iso=new Date(now).toISOString()
const last={sampledAt:iso,countersKnown:true,statusKnown:true,running:true,disabled:false}
const server={enabled:true,updatedAt:iso},settings={enabled:true,intervalSec:30},attempt={serverUpdatedAt:iso,lastAttemptAt:iso,lastSucceededAt:iso,status:"ok",error:null}
assert.equal(trafficBindingStatus(last,false,server,settings,attempt,now),"online")
assert.equal(trafficBindingStatus({...last,statusKnown:false},false,server,settings,attempt,now),"unknown")
assert.equal(trafficBindingStatus({...last,disabled:true},false,server,settings,attempt,now),"offline")
assert.equal(trafficBindingStatus(last,true,server,settings,attempt,now),"unknown")
assert.equal(trafficBindingStatus({...last,disabled:true},true,server,settings,attempt,now),"offline")
for(const status of["error","pending"])assert.equal(trafficBindingStatus(last,false,server,settings,{...attempt,status},now),"unknown")
assert.equal(trafficBindingStatus(last,false,server,settings,attempt,now+90_001),"unknown")
assert.equal(trafficBindingStatus(last,false,{...server,enabled:false},settings,attempt,now),"unknown")
for(const value of[true,"true","yes"])assert.equal(parseTrafficFlag(value),true)
for(const value of[false,"false","no"])assert.equal(parseTrafficFlag(value),false)
for(const value of[undefined,null,"bad",{},0,1])assert.throws(()=>parseTrafficFlag(value))
console.log("binding native status provenance/TTL/peer configuration/boolean guards tests ok")
+9 -7
View File
@@ -6,7 +6,7 @@ import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js"
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
import { MikrotikClient } from "./mikrotik.js" import { MikrotikClient } from "./mikrotik.js"
import { bpsToMbps, shouldIncludeIface } from "./traffic-rate.js" import { bpsToMbps, shouldIncludeIface } from "./traffic-rate.js"
import { observedTrafficRate, parseTrafficCounter } from "./traffic-observation.js" import { observedTrafficRate, parseTrafficCounter, parseTrafficFlag } from "./traffic-observation.js"
import { rememberServerIfaces } from "./traffic-flow-ifindex.js" import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
import { recordTrafficCollectionAttempt } from "./traffic-collection-state.js" import { recordTrafficCollectionAttempt } from "./traffic-collection-state.js"
@@ -133,16 +133,18 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
rememberServerIfaces(srv.id, ifaces) rememberServerIfaces(srv.id, ifaces)
const prevWave = await readPreviousWave(srv.id) const prevWave = await readPreviousWave(srv.id)
const nowMs = Date.parse(now) const nowMs = Date.parse(now)
if (!Array.isArray(ifaces) || ifaces.some(i => typeof i.name !== "string" || !i.name.trim()) || new Set(ifaces.map(i => i.name)).size !== ifaces.length)
throw new Error("RouterOS вернул некорректный каталог интерфейсов")
let sumRxMbps = 0 let sumRxMbps = 0
let sumTxMbps = 0 let sumTxMbps = 0
let includedInterfaces = 0 let includedInterfaces = 0
let totalsMeasured = true let totalsMeasured = true
const rows = ifaces.map((i) => { const rows = ifaces.map((i) => {
const interfaceName = i.name ?? "unknown" const interfaceName = i.name!
const rxBytes = toNum(i["rx-byte"]) const rxBytes = toNum(i["rx-byte"])
const txBytes = toNum(i["tx-byte"]) const txBytes = toNum(i["tx-byte"])
const running = (i.running ?? "false") === "true" const running = parseTrafficFlag(i.running)
const disabled = (i.disabled ?? "false") === "true" const disabled = parseTrafficFlag(i.disabled)
const { rxBps, txBps, measured } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs) const { rxBps, txBps, measured } = sampleRate(prevWave, waveKey(interfaceName), rxBytes, txBytes, nowMs)
if (shouldIncludeIface(interfaceName, running, disabled)) { if (shouldIncludeIface(interfaceName, running, disabled)) {
includedInterfaces++ includedInterfaces++
@@ -159,7 +161,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes, txBytes,
rxBps: Math.round(rxBps), rxBps: Math.round(rxBps),
txBps: Math.round(txBps), txBps: Math.round(txBps),
flags: encodeTrafficFlags(running, disabled, true), flags: encodeTrafficFlags(running, disabled, true, true),
} }
}) })
try { try {
@@ -170,7 +172,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
if (!interfaceName || !peerPublicKey) continue if (!interfaceName || !peerPublicKey) continue
const rxBytes = toNum(p.rx) const rxBytes = toNum(p.rx)
const txBytes = toNum(p.tx) const txBytes = toNum(p.tx)
const disabled = (p.disabled ?? "false") === "true" || p.disabled === "yes" const disabled = parseTrafficFlag(p.disabled)
const running = !disabled const running = !disabled
const { rxBps, txBps } = sampleRate( const { rxBps, txBps } = sampleRate(
prevWave, prevWave,
@@ -188,7 +190,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes, txBytes,
rxBps: Math.round(rxBps), rxBps: Math.round(rxBps),
txBps: Math.round(txBps), txBps: Math.round(txBps),
flags: encodeTrafficFlags(running, disabled, true), flags: encodeTrafficFlags(running, disabled, true, true),
}) })
} }
} catch { } catch {
+10
View File
@@ -4,6 +4,16 @@ export type TrafficCurrentState = "current" | "unknown" | "pending" | "error" |
export interface TrafficAttempt { export interface TrafficAttempt {
serverUpdatedAt: string | Date; lastAttemptAt: string | Date; lastSucceededAt: string | Date | null; status: string; error: string | null serverUpdatedAt: string | Date; lastAttemptAt: string | Date; lastSucceededAt: string | Date | null; status: string; error: string | null
} }
/** Interface running is a native state; enabled WG peer is only configuration, not handshake proof. */
export function trafficBindingStatus(last: { sampledAt: string; countersKnown: boolean; statusKnown: boolean; running: boolean; disabled: boolean } | undefined,
peer: boolean, server: { enabled: boolean; updatedAt: string | Date },
settings: { enabled: boolean; intervalSec: number } | undefined, attempt: TrafficAttempt | undefined, now: number): "online" | "offline" | "unknown" {
if (!last?.countersKnown || !last.statusKnown) return "unknown"
const observed = applyTrafficFreshness({ rxNow: true, txNow: true, rxSeries: [], txSeries: [], sampledAt: last.sampledAt }, server, settings, attempt, now)
if (observed.currentState !== "current") return "unknown"
if (last.disabled || !last.running) return "offline"
return peer ? "unknown" : "online"
}
export function mergeTrafficObservations(parts: TrafficObservations[]): TrafficObservations { export function mergeTrafficObservations(parts: TrafficObservations[]): TrafficObservations {
const oldest = (key: "sampledAt" | "lastAttemptAt" | "lastSucceededAt" | "expiresAt") => { const oldest = (key: "sampledAt" | "lastAttemptAt" | "lastSucceededAt" | "expiresAt") => {
const times = parts.map(p => Date.parse(p[key] ?? "")) const times = parts.map(p => Date.parse(p[key] ?? ""))
@@ -1,5 +1,5 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { observedTrafficRate, parseTrafficCounter } from "./traffic-observation.js" import { observedTrafficRate, parseTrafficCounter, parseTrafficFlag } from "./traffic-observation.js"
import { collectSourceSignals } from "./alert-engine/collector-source-signals.js" import { collectSourceSignals } from "./alert-engine/collector-source-signals.js"
import fs from "node:fs" import fs from "node:fs"
import vm from "node:vm" import vm from "node:vm"
@@ -41,7 +41,7 @@ assert.equal(ast.statements.filter(n => ts.isFunctionDeclaration(n) && n.name &&
let inserted: unknown[] = [], wave = new Map<string, typeof previous>(), counters: unknown = "1000" let inserted: unknown[] = [], wave = new Map<string, typeof previous>(), counters: unknown = "1000"
let included = true let included = true
const context = vm.createContext({ const context = vm.createContext({
Date, observedTrafficRate, parseTrafficCounter, bpsToMbps, shouldIncludeIface, Date, observedTrafficRate, parseTrafficCounter, parseTrafficFlag, bpsToMbps, shouldIncludeIface,
SCHEDULER_RUN_SNAPSHOT_VERSION: 1, servers: {}, trafficSamples: {}, trafficSettings: {}, eq: () => ({}), SCHEDULER_RUN_SNAPSHOT_VERSION: 1, servers: {}, trafficSamples: {}, trafficSettings: {}, eq: () => ({}),
getSettingsRow: async () => ({}), readPreviousWave: async () => wave, rememberServerIfaces: () => {}, getSettingsRow: async () => ({}), readPreviousWave: async () => wave, rememberServerIfaces: () => {},
encodeTrafficFlags: () => 1, encodeTrafficFlags: () => 1,
@@ -1,4 +1,9 @@
import { rateBpsFromDelta } from "./traffic-rate.js" import { rateBpsFromDelta } from "./traffic-rate.js"
export function parseTrafficFlag(raw: unknown): boolean {
if (raw === true || raw === "true" || raw === "yes") return true
if (raw === false || raw === "false" || raw === "no") return false
throw new Error("RouterOS вернул неизвестное состояние интерфейса")
}
/** RouterOS byte counters must be exact nonnegative integers, never fallback zero. */ /** RouterOS byte counters must be exact nonnegative integers, never fallback zero. */
export function parseTrafficCounter(raw: unknown): number { export function parseTrafficCounter(raw: unknown): number {
@@ -0,0 +1,33 @@
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 {collectTrafficOnce} from "./traffic-collector.js"
import trafficRoutes from "../routes/traffic.js"
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
const http=createServer((req,res)=>{res.setHeader("Content-Type","application/json");if(deny){res.statusCode=401;return res.end('{"message":"Unauthorized"}')}res.end(JSON.stringify(req.url?.includes("/wireguard/")?[{interface:"wg1","public-key":"fixturekey",rx:"1000",tx:"1000",disabled:peerDisabled}]:[{name:"ether1",running:flag,disabled:false,"rx-byte":"1000","tx-byte":"1000"}]))})
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{
await initDatabase();const updated=new Date(Date.now()-3600_000).toISOString()
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,updated])).rows[0].id)
await pool.query("INSERT INTO traffic_settings(id,enabled,interval_sec) VALUES(1,true,30) ON CONFLICT(id) DO UPDATE SET enabled=true,interval_sec=30")
await pool.query("INSERT INTO app_users(id,login,name) VALUES($1,$2,'Status fixture')",[userId,prefix+"login"])
for(const[name,key]of[["ether1",""],["wg1","fixturekey"]])await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type,peer_public_key) VALUES($1,$2,$3,$4,$5,$6)",[prefix+name,userId,serverId,name,key?"wg":"ether",key])
await collectTrafficOnce();let rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").status,"online");assert(rows[0].statusObservedAt&&rows[0].statusExpiresAt);cases++
assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="wg1").status,"unknown");cases++
peerDisabled=true;await collectTrafficOnce();rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="wg1").status,"offline");cases++
flag=false;await collectTrafficOnce();rows=await bindings();assert.equal(rows.find((r:{interfaceName:string})=>r.interfaceName==="ether1").status,"offline");cases++
flag="bad";let snap=await collectTrafficOnce();assert.equal(snap.servers[0].ok,false);rows=await bindings();assert(rows.every((r:{status:string})=>r.status==="unknown"));cases++
flag=true;deny=true;await collectTrafficOnce();rows=await bindings();assert(rows.every((r:{status:string})=>r.status==="unknown"));cases++
deny=false;await collectTrafficOnce();await pool.query("UPDATE traffic_samples SET flags=flags & 7 WHERE server_id=$1",[serverId]);rows=await bindings();assert(rows.every((r:{status:string})=>r.status==="unknown"));cases++
await collectTrafficOnce();await pool.query("UPDATE traffic_collection_state SET last_attempt_at=$1 WHERE server_id=$2",[new Date(Date.now()-100_000).toISOString(),serverId]);rows=await bindings();assert(rows.every((r:{status:string})=>r.status==="unknown"));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()}
}
+9 -5
View File
@@ -8,7 +8,7 @@ import {
type BuiltTrafficSeries, type BuiltTrafficSeries,
} from "./traffic-rate.js" } from "./traffic-rate.js"
import { readTrafficCollectionContext } from "./traffic-collection-state.js" import { readTrafficCollectionContext } from "./traffic-collection-state.js"
import { applyTrafficFreshness } from "./traffic-freshness.js" import { applyTrafficFreshness, trafficBindingStatus } from "./traffic-freshness.js"
import type { TrafficObservations } from "./traffic-rate.js" import type { TrafficObservations } from "./traffic-rate.js"
export interface BoundIfaceTrafficDto { export interface BoundIfaceTrafficDto {
@@ -36,7 +36,9 @@ export interface BoundIfaceTrafficDto {
txTotal: number txTotal: number
rxSeries: number[] rxSeries: number[]
txSeries: number[] txSeries: number[]
status: "online" | "offline" status: "online" | "offline" | "unknown"
statusObservedAt: string | null
statusExpiresAt: string | null
} }
export interface UserTrafficDto { export interface UserTrafficDto {
@@ -99,9 +101,9 @@ export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: num
const last = [...rows.filter((r) => const last = [...rows.filter((r) =>
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey, r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
)] )]
.sort((a, c) => a.sampledAt.localeCompare(c.sampledAt)) .sort((a, c) => Date.parse(a.sampledAt) - Date.parse(c.sampledAt))
.at(-1) .at(-1)
const running = Boolean(last?.running) && !last?.disabled const status = trafficBindingStatus(last, !!peerKey, server ?? {enabled:false,updatedAt:""}, context.settings, context.states.get(b.serverId), rangeEndMs)
interfaces.push({ interfaces.push({
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`, id: `${b.userId}:${b.serverId}:${b.interfaceName}:${peerKey || "_iface"}`,
bindingId: b.id, bindingId: b.id,
@@ -118,7 +120,9 @@ export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: num
serverSite: b.serverSite, serverSite: b.serverSite,
serverCountry: b.serverCountry, serverCountry: b.serverCountry,
...seriesFromBuilt(built), ...seriesFromBuilt(built),
status: running && server?.enabled ? "online" : "offline", status,
statusObservedAt: status !== "unknown" && last ? new Date(last.sampledAt).toISOString() : null,
statusExpiresAt: status !== "unknown" ? built.observations?.expiresAt ?? null : null,
}) })
} }
+26 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5 # UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md). IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
@@ -696,7 +696,7 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2091,3 +2091,27 @@ Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP S
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds. [Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push. Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
## 12.5.181 — достоверность статусов привязанных интерфейсов (2026-10-12)
Статус привязки теперь требует свежего успешного опроса текущей конфигурации устройства и подтверждённых native флагов. Ошибка, pending, disabled source, истёкший TTL или legacy row без status-known marker дают «Нет данных». Первое корректное измерение интерфейса может подтвердить его running, даже когда ещё недостаточно соседних counters для скорости. История и объёмы сохраняются.
Producer строго принимает true/false и native строки true/false/yes/no; missing/invalid flags больше не подменяются false. Новый бит8 status-known добавляется только новым успешным измерениям; bit4 counter-known не меняется, legacy rows не переписываются. Interface inventory проверяет имена/дубликаты. Включённый WG peer означает конфигурацию, поэтому даёт unknown без handshake proof; disabled peer — «Неактивен». Это не проверка реального VPN-туннеля.
DTO привязок содержит statusObservedAt/statusExpiresAt. Frontend проверяет provenance и TTL, обновляет состояние по своему clock даже без повторной загрузки; прежний payload без дат не считается подтверждением. Общие ReUI layout/base-nova и компоненты сохранены.
| Состояние | Схема | Проверка |
| --- | --- | --- |
| Native running + свежий успешный poll | native flag → known marker → TTL → Работает | own SQL и UI1440/320 |
| Ошибка / старый flag / истёкший TTL | последнее измерение → Нет данных | own SQL и UI1440/320 |
| Enabled WireGuard peer | configuration flag → Нет данных | own SQL; handshake не заявлен |
TypeScript/backend build/ESLint и профильные unit проверки прошли.8 E2E15.7s (2new+6mobile), после последней правки final delta2 passed10s. Один повтор первоначально не запустился из-за Windows sandbox/SWC access denied; корректный разрешённый запуск прошёл, это не UI failure.
[Own PG SQL](ux-ui/acceptance/traffic-status-sql-2026-10-12.json):48 cases9counter+14history+10TCP SSE+6volume+9status,36163ms, без unavailable skip. Новые9 проверяют настоящий collector→SQL→GET bindings на локальном HTTP RouterOS fixture: первое измерение, enabled/disabled peer, inactive interface, malformed flag,401,legacy bit,stale и disabled source. OwnDB mm_accept_166_1791818856762/ограниченная role/container удалены, отсутствие проверено. Первый запуск harness имел неверный путь распаковки; тесты не начались, его временная база/роль также удалены; пути исправлены до приведённого успешного результата.
[Source454/524](ux-ui/acceptance/traffic-status-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-status-compiled-2026-10-12.json) SHA256 parity, обе production builds прошли. Frontend ux-status181 сначала проверен с backend ux-volume180 [interop](ux-ui/acceptance/traffic-status-interop-2026-10-12.json); затем [both ux-status181](ux-ui/acceptance/traffic-status-runtime-2026-10-12.json) healthy/running/imageIds. Только .env tags, rollback ux-volume180, без копирования secret env и новой миграции.
[Native GET](ux-ui/acceptance/traffic-status-native-2026-10-12.json): оба CHR доступны, interface counters и boolean flags валидны; DB read-only, RouterOS config writes0/password changes0/messages0. [Browser1440/320](ux-ui/acceptance/traffic-status-deployed-2026-10-12.json): actual API/live/receipt labels без ошибок/overflow. Положительные binding branches доказаны собственными SQL/UI fixtures, не выданы за существующие пользовательские привязки стенда. Вкладку следует перезагрузить.
Следующий182 — полнота peer-среза при optional endpoint failure и пропуски истории. Interface running не доказывает end-to-end connectivity, enabled peer не доказывает handshake. Coverage/recreated identity/restore/multi-instance/scale/external integrations остаются открыты в12.5. Локальный коммит без push.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-12 ## Актуальная точка продолжения — 2026-10-12
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты. Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
@@ -371,6 +371,6 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09) ## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md). Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+25 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,30 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
## 12.5.181 — достоверность статусов привязанных интерфейсов (2026-10-12)
Статус привязки теперь требует свежего успешного опроса текущей конфигурации устройства и подтверждённых native флагов. Ошибка, pending, disabled source, истёкший TTL или legacy row без status-known marker дают «Нет данных». Первое корректное измерение интерфейса может подтвердить его running, даже когда ещё недостаточно соседних counters для скорости. История и объёмы сохраняются.
Producer строго принимает true/false и native строки true/false/yes/no; missing/invalid flags больше не подменяются false. Новый бит8 status-known добавляется только новым успешным измерениям; bit4 counter-known не меняется, legacy rows не переписываются. Interface inventory проверяет имена/дубликаты. Включённый WG peer означает конфигурацию, поэтому даёт unknown без handshake proof; disabled peer — «Неактивен». Это не проверка реального VPN-туннеля.
DTO привязок содержит statusObservedAt/statusExpiresAt. Frontend проверяет provenance и TTL, обновляет состояние по своему clock даже без повторной загрузки; прежний payload без дат не считается подтверждением. Общие ReUI layout/base-nova и компоненты сохранены.
| Состояние | Схема | Проверка |
| --- | --- | --- |
| Native running + свежий успешный poll | native flag → known marker → TTL → Работает | own SQL и UI1440/320 |
| Ошибка / старый flag / истёкший TTL | последнее измерение → Нет данных | own SQL и UI1440/320 |
| Enabled WireGuard peer | configuration flag → Нет данных | own SQL; handshake не заявлен |
TypeScript/backend build/ESLint и профильные unit проверки прошли.8 E2E15.7s (2new+6mobile), после последней правки final delta2 passed10s. Один повтор первоначально не запустился из-за Windows sandbox/SWC access denied; корректный разрешённый запуск прошёл, это не UI failure.
[Own PG SQL](ux-ui/acceptance/traffic-status-sql-2026-10-12.json):48 cases9counter+14history+10TCP SSE+6volume+9status,36163ms, без unavailable skip. Новые9 проверяют настоящий collector→SQL→GET bindings на локальном HTTP RouterOS fixture: первое измерение, enabled/disabled peer, inactive interface, malformed flag,401,legacy bit,stale и disabled source. OwnDB mm_accept_166_1791818856762/ограниченная role/container удалены, отсутствие проверено. Первый запуск harness имел неверный путь распаковки; тесты не начались, его временная база/роль также удалены; пути исправлены до приведённого успешного результата.
[Source454/524](ux-ui/acceptance/traffic-status-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-status-compiled-2026-10-12.json) SHA256 parity, обе production builds прошли. Frontend ux-status181 сначала проверен с backend ux-volume180 [interop](ux-ui/acceptance/traffic-status-interop-2026-10-12.json); затем [both ux-status181](ux-ui/acceptance/traffic-status-runtime-2026-10-12.json) healthy/running/imageIds. Только .env tags, rollback ux-volume180, без копирования secret env и новой миграции.
[Native GET](ux-ui/acceptance/traffic-status-native-2026-10-12.json): оба CHR доступны, interface counters и boolean flags валидны; DB read-only, RouterOS config writes0/password changes0/messages0. [Browser1440/320](ux-ui/acceptance/traffic-status-deployed-2026-10-12.json): actual API/live/receipt labels без ошибок/overflow. Положительные binding branches доказаны собственными SQL/UI fixtures, не выданы за существующие пользовательские привязки стенда. Вкладку следует перезагрузить.
Следующий182 — полнота peer-среза при optional endpoint failure и пропуски истории. Interface running не доказывает end-to-end connectivity, enabled peer не доказывает handshake. Coverage/recreated identity/restore/multi-instance/scale/external integrations остаются открыты в12.5. Локальный коммит без push.
## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12) ## 12.5.180 — точность учтённых объёмов и бинарные единицы (2026-10-12)
Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода. Read-only [baseline](ux-ui/acceptance/traffic-volume-baseline-2026-10-12.json) подтверждён до правки: measured1KiB/1MiB превращались в API0 и0.0GB;100×1MiB также aggregate0;1GiB выдавался с decimal labelGB. Убрано toFixed(1) из buildTrafficFromSamples/mergeBuiltTraffic: округление отдельных слагаемых больше не теряет суммарный трафик. Учёт остаётся по known соседним counter deltas, reset/unknown не добавляют выдуманные bytes. Отсутствующий baseline и coverage gaps по-прежнему ограничивают полноту периода.
+27 -3
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-12 # Оставшаяся внешняя приёмка — 2026-10-12
Это актуальная матрица после 12.5.180. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии. Это актуальная матрица после 12.5.181. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-volume180`, frontend `ux-volume180`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись. Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-status181`, frontend `ux-status181`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
| Раздел | Подтверждено | Осталось / условие завершения | | Раздел | Подтверждено | Осталось / условие завершения |
| --- | --- | --- | | --- | --- | --- |
@@ -18,7 +18,7 @@
| Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята | | Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята |
| Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты | | Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты |
| Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения | | Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения |
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; history178/legacy provenance/current TTL/manual truth/SQL9+14/35unique E2E/native GET/deployed passed; live179 complete/disable-config/bounded fallback/ownSQL10/nativeSSE2/liveUI2 passed; volume180 exact/unit marker/ownSQL6/18E2E/API/liveUI/deployed passed; binding status freshness181 next; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию | | Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; history178/legacy provenance/current TTL/manual truth/SQL9+14/35unique E2E/native GET/deployed passed; live179 complete/disable-config/bounded fallback/ownSQL10/nativeSSE2/liveUI2 passed; volume180 exact/unit marker/ownSQL6/18E2E/API/liveUI/deployed passed; binding status181 native/ownSQL9/8E2E/deployed passed; peer slice completeness182 next; последующие изменения проверять отдельно; scoped axe не означает полную WCAG сертификацию |
Доказательства и точные ограничения: [полная история этапа12](ux-ui-stage-12.md), [внешний протокол](ux-ui-external-acceptance.md), [BGP93](ux-ui/acceptance/bgp-failover-2026-10-10.json), [паспорт стенда](ux-ui-test-server.md). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение. Доказательства и точные ограничения: [полная история этапа12](ux-ui-stage-12.md), [внешний протокол](ux-ui-external-acceptance.md), [BGP93](ux-ui/acceptance/bgp-failover-2026-10-10.json), [паспорт стенда](ux-ui-test-server.md). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
@@ -1006,3 +1006,27 @@ Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP S
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds. [Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push. Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
## 12.5.181 — достоверность статусов привязанных интерфейсов (2026-10-12)
Статус привязки теперь требует свежего успешного опроса текущей конфигурации устройства и подтверждённых native флагов. Ошибка, pending, disabled source, истёкший TTL или legacy row без status-known marker дают «Нет данных». Первое корректное измерение интерфейса может подтвердить его running, даже когда ещё недостаточно соседних counters для скорости. История и объёмы сохраняются.
Producer строго принимает true/false и native строки true/false/yes/no; missing/invalid flags больше не подменяются false. Новый бит8 status-known добавляется только новым успешным измерениям; bit4 counter-known не меняется, legacy rows не переписываются. Interface inventory проверяет имена/дубликаты. Включённый WG peer означает конфигурацию, поэтому даёт unknown без handshake proof; disabled peer — «Неактивен». Это не проверка реального VPN-туннеля.
DTO привязок содержит statusObservedAt/statusExpiresAt. Frontend проверяет provenance и TTL, обновляет состояние по своему clock даже без повторной загрузки; прежний payload без дат не считается подтверждением. Общие ReUI layout/base-nova и компоненты сохранены.
| Состояние | Схема | Проверка |
| --- | --- | --- |
| Native running + свежий успешный poll | native flag → known marker → TTL → Работает | own SQL и UI1440/320 |
| Ошибка / старый flag / истёкший TTL | последнее измерение → Нет данных | own SQL и UI1440/320 |
| Enabled WireGuard peer | configuration flag → Нет данных | own SQL; handshake не заявлен |
TypeScript/backend build/ESLint и профильные unit проверки прошли.8 E2E15.7s (2new+6mobile), после последней правки final delta2 passed10s. Один повтор первоначально не запустился из-за Windows sandbox/SWC access denied; корректный разрешённый запуск прошёл, это не UI failure.
[Own PG SQL](ux-ui/acceptance/traffic-status-sql-2026-10-12.json):48 cases9counter+14history+10TCP SSE+6volume+9status,36163ms, без unavailable skip. Новые9 проверяют настоящий collector→SQL→GET bindings на локальном HTTP RouterOS fixture: первое измерение, enabled/disabled peer, inactive interface, malformed flag,401,legacy bit,stale и disabled source. OwnDB mm_accept_166_1791818856762/ограниченная role/container удалены, отсутствие проверено. Первый запуск harness имел неверный путь распаковки; тесты не начались, его временная база/роль также удалены; пути исправлены до приведённого успешного результата.
[Source454/524](ux-ui/acceptance/traffic-status-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-status-compiled-2026-10-12.json) SHA256 parity, обе production builds прошли. Frontend ux-status181 сначала проверен с backend ux-volume180 [interop](ux-ui/acceptance/traffic-status-interop-2026-10-12.json); затем [both ux-status181](ux-ui/acceptance/traffic-status-runtime-2026-10-12.json) healthy/running/imageIds. Только .env tags, rollback ux-volume180, без копирования secret env и новой миграции.
[Native GET](ux-ui/acceptance/traffic-status-native-2026-10-12.json): оба CHR доступны, interface counters и boolean flags валидны; DB read-only, RouterOS config writes0/password changes0/messages0. [Browser1440/320](ux-ui/acceptance/traffic-status-deployed-2026-10-12.json): actual API/live/receipt labels без ошибок/overflow. Положительные binding branches доказаны собственными SQL/UI fixtures, не выданы за существующие пользовательские привязки стенда. Вкладку следует перезагрузить.
Следующий182 — полнота peer-среза при optional endpoint failure и пропуски истории. Interface running не доказывает end-to-end connectivity, enabled peer не доказывает handshake. Coverage/recreated identity/restore/multi-instance/scale/external integrations остаются открыты в12.5. Локальный коммит без push.
+25 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-12: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Актуальное продолжение 2026-10-12: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3396,3 +3396,27 @@ Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP S
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds. [Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push. Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
## 12.5.181 — достоверность статусов привязанных интерфейсов (2026-10-12)
Статус привязки теперь требует свежего успешного опроса текущей конфигурации устройства и подтверждённых native флагов. Ошибка, pending, disabled source, истёкший TTL или legacy row без status-known marker дают «Нет данных». Первое корректное измерение интерфейса может подтвердить его running, даже когда ещё недостаточно соседних counters для скорости. История и объёмы сохраняются.
Producer строго принимает true/false и native строки true/false/yes/no; missing/invalid flags больше не подменяются false. Новый бит8 status-known добавляется только новым успешным измерениям; bit4 counter-known не меняется, legacy rows не переписываются. Interface inventory проверяет имена/дубликаты. Включённый WG peer означает конфигурацию, поэтому даёт unknown без handshake proof; disabled peer — «Неактивен». Это не проверка реального VPN-туннеля.
DTO привязок содержит statusObservedAt/statusExpiresAt. Frontend проверяет provenance и TTL, обновляет состояние по своему clock даже без повторной загрузки; прежний payload без дат не считается подтверждением. Общие ReUI layout/base-nova и компоненты сохранены.
| Состояние | Схема | Проверка |
| --- | --- | --- |
| Native running + свежий успешный poll | native flag → known marker → TTL → Работает | own SQL и UI1440/320 |
| Ошибка / старый flag / истёкший TTL | последнее измерение → Нет данных | own SQL и UI1440/320 |
| Enabled WireGuard peer | configuration flag → Нет данных | own SQL; handshake не заявлен |
TypeScript/backend build/ESLint и профильные unit проверки прошли.8 E2E15.7s (2new+6mobile), после последней правки final delta2 passed10s. Один повтор первоначально не запустился из-за Windows sandbox/SWC access denied; корректный разрешённый запуск прошёл, это не UI failure.
[Own PG SQL](ux-ui/acceptance/traffic-status-sql-2026-10-12.json):48 cases9counter+14history+10TCP SSE+6volume+9status,36163ms, без unavailable skip. Новые9 проверяют настоящий collector→SQL→GET bindings на локальном HTTP RouterOS fixture: первое измерение, enabled/disabled peer, inactive interface, malformed flag,401,legacy bit,stale и disabled source. OwnDB mm_accept_166_1791818856762/ограниченная role/container удалены, отсутствие проверено. Первый запуск harness имел неверный путь распаковки; тесты не начались, его временная база/роль также удалены; пути исправлены до приведённого успешного результата.
[Source454/524](ux-ui/acceptance/traffic-status-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-status-compiled-2026-10-12.json) SHA256 parity, обе production builds прошли. Frontend ux-status181 сначала проверен с backend ux-volume180 [interop](ux-ui/acceptance/traffic-status-interop-2026-10-12.json); затем [both ux-status181](ux-ui/acceptance/traffic-status-runtime-2026-10-12.json) healthy/running/imageIds. Только .env tags, rollback ux-volume180, без копирования secret env и новой миграции.
[Native GET](ux-ui/acceptance/traffic-status-native-2026-10-12.json): оба CHR доступны, interface counters и boolean flags валидны; DB read-only, RouterOS config writes0/password changes0/messages0. [Browser1440/320](ux-ui/acceptance/traffic-status-deployed-2026-10-12.json): actual API/live/receipt labels без ошибок/overflow. Положительные binding branches доказаны собственными SQL/UI fixtures, не выданы за существующие пользовательские привязки стенда. Вкладку следует перезагрузить.
Следующий182 — полнота peer-среза при optional endpoint failure и пропуски истории. Interface running не доказывает end-to-end connectivity, enabled peer не доказывает handshake. Coverage/recreated identity/restore/multi-instance/scale/external integrations остаются открыты в12.5. Локальный коммит без push.
+26 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10 # Тестовый сервер UX/UI — 2026-10-10
Сервер161.104.46.222: завершён12.5.180 — точные учтённые объёмы без early rounding, explicit GiB API/КиБ-МиБ-ГиБ UI, unknown unit→—, N+1 server-status query removed. OwnSQL9+14+10+6/31474ms,18E2E/36.3s/types/build/ESLint/1036compiled parity. Both ux-volume180 healthy/running/API2/real-live browser2 passed; ownDB/role/container removed. Следующий181 — проверить freshness статусов привязок и peer/interface flags;12.5 открыта. Сервер161.104.46.222: завершён12.5.181 — подтверждённые свежие статусы привязок, strict native boolean flags и отдельный unknown для enabled WireGuard peer. OwnSQL48/36163ms,8E2E/15.7s+final delta2/10s/types/build/ESLint/1036compiled parity. Both ux-status181 healthy/running/native2/browser2; ownDB/role/container удалены. Следующий182 — полнота peer-среза и история пропусков;12.5 открыта.
| Параметр | Проверенное состояние | | Параметр | Проверенное состояние |
| --- | --- | | --- | --- |
@@ -14,7 +14,7 @@
| PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed | | PostgreSQL | postgres:18-alpine,18.6;mmapp-test-postgres healthy;SELECT version() passed |
| Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data | | Данные | Отдельный Compose project mmapp-test,том mmapp-test_mmapp-pgdata и каталог ./data |
| Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска | | Доступ | PostgreSQL не публикуется;frontend3000/backend8000 привязаны к127.0.0.1 после запуска |
| Образы приложения | mmapp-test/backend:ux-volume180 и mmapp-test/frontend:ux-volume180; запущены | | Образы приложения | mmapp-test/backend:ux-status181 и mmapp-test/frontend:ux-status181; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example). Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -1822,3 +1822,27 @@ Owned Node22/PG18.6 test:traffic-rate31474ms:9 counters+14history+10actual TCP S
[Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds. [Actual volume API](ux-ui/acceptance/traffic-volume-api-2026-10-12.json) показывает GiB и неокруглённые quantities обоих CHR из штатных collector rows; этот checker не отправлял новых native commands. [Browser1440/320](ux-ui/acceptance/traffic-volume-deployed-2026-10-12.json):3APIs200, actual live/receipt labels/provenance, no errors/overflow. Рабочая конфигурация/пароли не менялись, messages0; auto collectors остаются штатными. Это не WAN byte billing/capacity/SLA proof и не exact arithmetic acceptance за пределами safe integer/scale bounds.
Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push. Следующий181 — freshness статуса привязанных интерфейсов: enabled device+old running flag не должен объявлять online после failed/stale poll; peer enabled не доказывает handshake. Проверить native flag provenance и scalar shape, не переписывать legacy rows по догадке. Coverage/peer completeness/recreated interfaces/restore/multi-instance/scale/external integrations остаются12.5. Локальный коммит без push.
## 12.5.181 — достоверность статусов привязанных интерфейсов (2026-10-12)
Статус привязки теперь требует свежего успешного опроса текущей конфигурации устройства и подтверждённых native флагов. Ошибка, pending, disabled source, истёкший TTL или legacy row без status-known marker дают «Нет данных». Первое корректное измерение интерфейса может подтвердить его running, даже когда ещё недостаточно соседних counters для скорости. История и объёмы сохраняются.
Producer строго принимает true/false и native строки true/false/yes/no; missing/invalid flags больше не подменяются false. Новый бит8 status-known добавляется только новым успешным измерениям; bit4 counter-known не меняется, legacy rows не переписываются. Interface inventory проверяет имена/дубликаты. Включённый WG peer означает конфигурацию, поэтому даёт unknown без handshake proof; disabled peer — «Неактивен». Это не проверка реального VPN-туннеля.
DTO привязок содержит statusObservedAt/statusExpiresAt. Frontend проверяет provenance и TTL, обновляет состояние по своему clock даже без повторной загрузки; прежний payload без дат не считается подтверждением. Общие ReUI layout/base-nova и компоненты сохранены.
| Состояние | Схема | Проверка |
| --- | --- | --- |
| Native running + свежий успешный poll | native flag → known marker → TTL → Работает | own SQL и UI1440/320 |
| Ошибка / старый flag / истёкший TTL | последнее измерение → Нет данных | own SQL и UI1440/320 |
| Enabled WireGuard peer | configuration flag → Нет данных | own SQL; handshake не заявлен |
TypeScript/backend build/ESLint и профильные unit проверки прошли.8 E2E15.7s (2new+6mobile), после последней правки final delta2 passed10s. Один повтор первоначально не запустился из-за Windows sandbox/SWC access denied; корректный разрешённый запуск прошёл, это не UI failure.
[Own PG SQL](ux-ui/acceptance/traffic-status-sql-2026-10-12.json):48 cases9counter+14history+10TCP SSE+6volume+9status,36163ms, без unavailable skip. Новые9 проверяют настоящий collector→SQL→GET bindings на локальном HTTP RouterOS fixture: первое измерение, enabled/disabled peer, inactive interface, malformed flag,401,legacy bit,stale и disabled source. OwnDB mm_accept_166_1791818856762/ограниченная role/container удалены, отсутствие проверено. Первый запуск harness имел неверный путь распаковки; тесты не начались, его временная база/роль также удалены; пути исправлены до приведённого успешного результата.
[Source454/524](ux-ui/acceptance/traffic-status-source-2026-10-12.json)/[compiled1036](ux-ui/acceptance/traffic-status-compiled-2026-10-12.json) SHA256 parity, обе production builds прошли. Frontend ux-status181 сначала проверен с backend ux-volume180 [interop](ux-ui/acceptance/traffic-status-interop-2026-10-12.json); затем [both ux-status181](ux-ui/acceptance/traffic-status-runtime-2026-10-12.json) healthy/running/imageIds. Только .env tags, rollback ux-volume180, без копирования secret env и новой миграции.
[Native GET](ux-ui/acceptance/traffic-status-native-2026-10-12.json): оба CHR доступны, interface counters и boolean flags валидны; DB read-only, RouterOS config writes0/password changes0/messages0. [Browser1440/320](ux-ui/acceptance/traffic-status-deployed-2026-10-12.json): actual API/live/receipt labels без ошибок/overflow. Положительные binding branches доказаны собственными SQL/UI fixtures, не выданы за существующие пользовательские привязки стенда. Вкладку следует перезагрузить.
Следующий182 — полнота peer-среза при optional endpoint failure и пропуски истории. Interface running не доказывает end-to-end connectivity, enabled peer не доказывает handshake. Coverage/recreated identity/restore/multi-instance/scale/external 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.181",
"runtime": {
"backend": "ux-status181",
"frontend": "ux-status181"
},
"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.181",
"newFrontend": "ux-status181",
"oldBackend": "ux-volume180",
"readerAccepted": true,
"serverCount": 2,
"pageErrors": 0,
"bound": "Additive observed DTO reader compatibility; legacy numeric-only paths verified separately as unknown"
}
@@ -0,0 +1,31 @@
{
"date": "2026-10-12",
"block": "12.5.181",
"version": "1.0.0-test.status181",
"readOnlyDatabase": true,
"cases": [
{
"sourceId": 1,
"target": "194.226.121.150",
"identityRead": true,
"interfaceRead": true,
"interfaceCount": 2,
"countersValid": true,
"nativeBooleanFlagsValidated": true
},
{
"sourceId": 2,
"target": "104.165.244.7",
"identityRead": true,
"interfaceRead": true,
"interfaceCount": 2,
"countersValid": true,
"nativeBooleanFlagsValidated": true
}
],
"dbWrites": 0,
"routerConfigWrites": 0,
"messages": 0,
"migrationApplied": true,
"passed": true
}
@@ -0,0 +1,25 @@
{
"date": "2026-10-12",
"block": "12.5.181",
"secretEnvCopied": false,
"rollbackTags": {
"MMAPP_IMAGE_TAG": "ux-volume180",
"BACKEND_IMAGE_TAG": "ux-volume180"
},
"services": {
"backend": {
"image": "mmapp-test/backend:ux-status181",
"imageId": "sha256:28dffe2d6c13e3b34ef6f3b9ece3de051f84b9688f5a47fc67e96ae8cafd6d9e",
"state": "running",
"health": "healthy",
"matchedVerifiedImage": true
},
"frontend": {
"image": "mmapp-test/frontend:ux-status181",
"imageId": "sha256:6d0930bd7d656dea9e81b88d0a7333758d6a1f35dc4e9833b5d89d74bfa81bd5",
"state": "running",
"health": null,
"matchedVerifiedImage": true
}
}
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-12",
"block": "12.5.181",
"matched": {
"backend": 454,
"frontend": 524
},
"different": [],
"missing": []
}
@@ -0,0 +1,31 @@
{
"date": "2026-10-12",
"block": "12.5.181",
"passed": true,
"profiles": [
{
"date": "2026-10-12",
"block": "12.5.181",
"database": "mm_accept_166_1791818856762",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:traffic-rate",
"passed": true,
"durationMs": 36163,
"ownedTrafficSqlCases": 9,
"ownedHistorySqlCases": 14,
"ownedLiveSqlCases": 10,
"ownedVolumeSqlCases": 6,
"ownedStatusSqlCases": 9,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
}
]
}
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict"
import { projectTrafficBindingStatus } from "./traffic-binding-status.js"
const at=Date.parse("2026-10-12T12:00:00Z"),s=new Date(at).toISOString(),expiry=new Date(at+90_000).toISOString()
assert.equal(projectTrafficBindingStatus("online",s,expiry,at),"online")
assert.equal(projectTrafficBindingStatus("offline",s,expiry,at),"offline")
assert.equal(projectTrafficBindingStatus("online",undefined,undefined,at),"unknown")
assert.equal(projectTrafficBindingStatus("online",s,expiry,at+90_001),"unknown")
assert.equal(projectTrafficBindingStatus("online",s,"bad",at),"unknown")
console.log("binding status legacy/expiry projection tests ok")
+6
View File
@@ -0,0 +1,6 @@
export function projectTrafficBindingStatus(status: unknown, observedAt: unknown, expiresAt: unknown, now = Date.now()): "online" | "offline" | "unknown" {
if ((status !== "online" && status !== "offline") || typeof observedAt !== "string" || typeof expiresAt !== "string") return "unknown"
const observed = Date.parse(observedAt), expires = Date.parse(expiresAt)
if (!Number.isFinite(observed) || !Number.isFinite(expires) || observed >= expires || now > expires || observed > now + (expires-observed)/3) return "unknown"
return status
}
+12
View File
@@ -0,0 +1,12 @@
import {test,expect} from "@playwright/test"
import {observedTrafficFixturePayload} from "./traffic-observation-fixture"
for(const width of[1440,320])test(`Статус интерфейса: fresh/expired/legacy ${width}`,async({page})=>{
const at=new Date("2026-10-12T12:00:00Z");await page.clock.install({time:at});await page.setViewportSize({width,height:1000});let legacy=false
const iface=()=>({id:"i1",bindingId:"b1",userId:"u1",userLogin:"user",userName:"User",interfaceName:"ether1",interfaceType:"ether",comment:"",serverId:"1",serverName:"CHR",serverSite:"TEST",serverCountry:"RU",status:"online",...(!legacy?{statusObservedAt:at.toISOString(),statusExpiresAt:new Date(at.getTime()+90_000).toISOString()}:{}),rxNow:0,txNow:0,rxPeak:0,txPeak:0,rxTotal:0,txTotal:0,rxSeries:[0,0],txSeries:[0,0]})
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:observedTrafficFixturePayload(path==="/api/auth/config"?{required:false}:path==="/api/traffic/servers"?{servers:[]}:path==="/api/traffic/users"?{users:[{id:"u1",login:"user",displayName:"User",role:"viewer",interfaces:[iface()],rxNow:0,txNow:0,rxPeak:0,txPeak:0,rxTotal:0,txTotal:0,rxSeries:[0,0],txSeries:[0,0]}]}:path==="/api/traffic/bound-interfaces"?{interfaces:[iface()]}:path==="/api/servers"?[]:{})})})
await page.goto("/traffic");await page.getByRole("group",{name:"Разделы трафика"}).getByRole("button",{name:"Клиенты",exact:true}).click()
await expect(page.getByText("Работает",{exact:true})).toBeVisible();await page.clock.fastForward(100_001)
await expect(page.getByText("Работает",{exact:true})).toHaveCount(0);await expect(page.getByText("Нет данных",{exact:true})).toBeVisible()
legacy=true;await page.getByRole("button",{name:"15м",exact:true}).click();await expect(page.getByText("Нет данных",{exact:true})).toBeVisible()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true)
})