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

This commit is contained in:
Denozordec committed 2026-10-12 20:23:07 +07:00
1 parent ebe62ae2ef
commit 1275dd7b9b
48 files changed
+1899 -187

No files matched your search

+89 -77
View File
@@ -2,6 +2,8 @@
import { useState, useMemo, useEffect, type ReactNode } from "react"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { projectTrafficObservations, type TrafficObservationMeta } from "@/lib/traffic-observations"
import { TrafficObservationStatus } from "@/components/traffic/traffic-observation-status"
import { TrafficDataTime } from "@/components/traffic/traffic-data-time"
import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame"
@@ -84,6 +86,7 @@ function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): st
// ─── data model ───────────────────────────────────────────────────────────────
interface BoundIfaceTraffic {
observations?: TrafficObservationMeta
id: string
bindingId: string
userId: string
@@ -98,75 +101,79 @@ interface BoundIfaceTraffic {
serverName: string
serverSite: string
serverCountry: string
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
rxSeries: (number|null)[]
txSeries: (number|null)[]
status: "online" | "offline"
}
interface ServerTraffic {
observations?: TrafficObservationMeta
id: string
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
sessions: number
rxSeries: number[]
txSeries: number[]
rxSeries: (number|null)[]
txSeries: (number|null)[]
boundIfaces: BoundIfaceTraffic[]
}
interface UserTraffic {
observations?: TrafficObservationMeta
id: string
login: string
displayName: string
role: string
active?: boolean
interfaces: BoundIfaceTraffic[]
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxSeries: number[]
txSeries: number[]
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
rxSeries: (number|null)[]
txSeries: (number|null)[]
}
interface LiveTrafficServer {
observations?: TrafficObservationMeta
id: string
name: string
site: string
country: string
status: "online" | "offline" | "degraded"
rxNow: number
txNow: number
rxPeak: number
txPeak: number
rxTotal: number
txTotal: number
rxNow: number | null
txNow: number | null
rxPeak: number | null
txPeak: number | null
rxTotal: number | null
txTotal: number | null
sessions: number
rxSeries: number[]
txSeries: number[]
rxSeries: (number|null)[]
txSeries: (number|null)[]
}
interface LiveTrafficInterface {
observations?: TrafficObservationMeta
name: string
running: boolean
disabled: boolean
rxNow: number
txNow: number
rxNow: number | null
txNow: number | null
}
function makeApiFetch(backendUrl: string) {
@@ -175,6 +182,13 @@ function makeApiFetch(backendUrl: string) {
}
}
function compareRates(a: number|null, b: number|null, direction: "asc"|"desc") {
if (a === null && b === null) return 0
if (a === null) return direction === "desc" ? -1 : 1
if (b === null) return direction === "desc" ? 1 : -1
return a - b
}
function hashSeed(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
@@ -278,14 +292,14 @@ function buildUserTraffic(
clients: BoundIfaceTraffic[],
active = true,
): UserTraffic {
const rxNow = clients.reduce((a, c) => a + c.rxNow, 0)
const txNow = clients.reduce((a, c) => a + c.txNow, 0)
const rxPeak = clients.reduce((a, c) => a + c.rxPeak, 0)
const txPeak = clients.reduce((a, c) => a + c.txPeak, 0)
const rxTotal = clients.reduce((a, c) => a + c.rxTotal, 0)
const txTotal = clients.reduce((a, c) => a + c.txTotal, 0)
const rxSeries = clients.reduce((a, c) => addSeries(a, c.rxSeries), Array(60).fill(0) as number[])
const txSeries = clients.reduce((a, c) => addSeries(a, c.txSeries), Array(60).fill(0) as number[])
const rxNow = clients.reduce((a, c) => a + (c.rxNow ?? 0), 0)
const txNow = clients.reduce((a, c) => a + (c.txNow ?? 0), 0)
const rxPeak = clients.reduce((a, c) => a + (c.rxPeak ?? 0), 0)
const txPeak = clients.reduce((a, c) => a + (c.txPeak ?? 0), 0)
const rxTotal = clients.reduce((a, c) => a + (c.rxTotal ?? 0), 0)
const txTotal = clients.reduce((a, c) => a + (c.txTotal ?? 0), 0)
const rxSeries = clients.reduce((a, c) => addSeries(a, c.rxSeries.map(v => v ?? 0)), Array(60).fill(0) as number[])
const txSeries = clients.reduce((a, c) => addSeries(a, c.txSeries.map(v => v ?? 0)), Array(60).fill(0) as number[])
return { id, login, displayName, role, active, interfaces: clients, rxNow, txNow, rxPeak, txPeak, rxTotal, txTotal, rxSeries, txSeries }
}
@@ -538,21 +552,21 @@ function OfflinePlaceholder({ text = "Нет данных — объект не
}
function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
rxTotal: number; txTotal: number; rxSeries: number[]; txSeries: number[]
rxTotal: number|null; txTotal: number|null; rxSeries: (number|null)[]; txSeries: (number|null)[]
}) {
return (
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4 [&>div]:min-w-0 [&_svg]:max-w-full">
<div>
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
<p className="text-xs text-muted-foreground mb-1">Учтено RX по замерам</p>
<p className="text-lg font-semibold tabular-nums">
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
{fmtGB(rxTotal)}
</p>
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
<p className="text-xs text-muted-foreground mb-1">Учтено TX по замерам</p>
<p className="text-lg font-semibold tabular-nums">
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
{fmtGB(txTotal)}
</p>
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
</div>
@@ -583,7 +597,8 @@ function ServerDetail({
<Flag code={sel.country} />{sel.site}
</span>
</DetailHeader>
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
<TrafficObservationStatus observations={sel.observations} unknown={sel.rxNow == null || sel.txNow == null} />
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
<div className="mt-4 pt-4 border-t">
<KpiStatGrid
aria-label="Скорость выбранного сервера"
@@ -689,7 +704,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
<Flag code={sel.serverCountry} />{sel.serverSite}
</span>
</DetailHeader>
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX" value={fmtRate(sel.rxNow)} />
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX" value={fmtRate(sel.txNow)} />
@@ -738,24 +753,16 @@ const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
site: s.site || "—",
country: s.country || "UN",
status: s.status,
rxNow: s.rxNow,
txNow: s.txNow,
rxPeak: s.rxPeak,
txPeak: s.txPeak,
rxTotal: s.rxTotal,
txTotal: s.txTotal,
sessions: s.sessions,
rxSeries: s.rxSeries,
txSeries: s.txSeries,
boundIfaces: [],
...projectTrafficObservations(s),
}
}
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
...c,
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
rxSeries: c.rxSeries,
txSeries: c.txSeries,
...projectTrafficObservations(c),
})
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
@@ -764,14 +771,19 @@ const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): User
...u,
role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role,
interfaces: ifaces,
rxSeries: u.rxSeries,
txSeries: u.txSeries,
...projectTrafficObservations(u),
}
}
export default function TrafficPage() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [trafficClock, setTrafficClock] = useState(() => Date.now())
useEffect(() => {
if (!isLive) return
const timer = setInterval(() => setTrafficClock(Date.now()), 10_000)
return () => clearInterval(timer)
}, [isLive])
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
@@ -945,9 +957,9 @@ export default function TrafficPage() {
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
}, [isLive, backendUrl])
const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers : []) : serverTraffic, [isLive, trafficReady, liveServers])
const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers : []) : userTraffic, [isLive, trafficReady, liveUsers])
const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces])
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 activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces.map(c => ({ ...c, ...projectTrafficObservations(c, trafficClock) })) : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces, trafficClock])
const visibleIfaces = useMemo(
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
[serverIfaces, hideDisabledIfaces],
@@ -1022,8 +1034,8 @@ export default function TrafficPage() {
.filter(s => !q || s.name.toLowerCase().includes(q) || s.site.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.name.localeCompare(b.name)
else if (sortField === "sessions") v = a.sessions - b.sessions
return sortDir === "desc" ? -v : v
@@ -1035,8 +1047,8 @@ export default function TrafficPage() {
.filter(u => !q || u.login.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.login.localeCompare(b.login)
else if (sortField === "sessions") v = a.interfaces.length - b.interfaces.length
return sortDir === "desc" ? -v : v
@@ -1052,8 +1064,8 @@ export default function TrafficPage() {
|| c.userLogin.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.interfaceName.localeCompare(b.interfaceName)
return sortDir === "desc" ? -v : v
})
@@ -1065,8 +1077,8 @@ export default function TrafficPage() {
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q) || c.site.toLowerCase().includes(q))
.sort((a, b) => {
let v = 0
if (sortField === "rx") v = a.rxNow - b.rxNow
else if (sortField === "tx") v = a.txNow - b.txNow
if (sortField === "rx") v = compareRates(a.rxNow, b.rxNow, sortDir)
else if (sortField === "tx") v = compareRates(a.txNow, b.txNow, sortDir)
else if (sortField === "name") v = a.name.localeCompare(b.name)
else if (sortField === "sessions") v = a.sessions - b.sessions
return sortDir === "desc" ? -v : v
@@ -1081,7 +1093,7 @@ export default function TrafficPage() {
const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = isLive ? currentDetail?.server : selServer
const detailServer = isLive ? (currentDetail?.server ? { ...currentDetail.server, ...projectTrafficObservations(currentDetail.server, trafficClock) } : null) : selServer
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])
@@ -1090,10 +1102,10 @@ export default function TrafficPage() {
: effectiveMode === "ifaces"
? activeBoundIfaces
: activeServerTraffic
const totalRx = kpiSource.reduce((a, s) => a + s.rxNow, 0)
const totalTx = kpiSource.reduce((a, s) => a + s.txNow, 0)
const peakRx = kpiSource.reduce((a, s) => Math.max(a, s.rxPeak), 0)
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
const totalRx = kpiSource.every(s => s.rxNow !== null) ? kpiSource.reduce((a, s) => a + (s.rxNow ?? 0), 0) : null
const totalTx = kpiSource.every(s => s.txNow !== null) ? kpiSource.reduce((a, s) => a + (s.txNow ?? 0), 0) : null
const peakRx = kpiSource.every(s => s.rxPeak !== null) ? kpiSource.reduce((a, s) => Math.max(a, s.rxPeak ?? 0), 0) : null
const peakTx = kpiSource.every(s => s.txPeak !== null) ? kpiSource.reduce((a, s) => Math.max(a, s.txPeak ?? 0), 0) : null
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
const ingestLine = flowIngestLine(flowStats)
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS traffic_collection_state (
server_id BIGINT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
server_updated_at TIMESTAMPTZ NOT NULL,
last_attempt_at TIMESTAMPTZ NOT NULL,
last_succeeded_at TIMESTAMPTZ,
status TEXT NOT NULL CHECK(status IN ('pending', 'ok', 'error')),
error 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-observation-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-observation-sql.test.ts && tsx src/services/traffic-history-sql.test.ts",
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.test.ts",
"test:pg": "tsx src/db/pg-pool.test.ts && tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
+9
View File
@@ -341,6 +341,15 @@ export const uptimeSettings = pgTable("uptime_settings", {
updatedAt: ts("updated_at").notNull().defaultNow(),
})
export const trafficCollectionState = pgTable("traffic_collection_state", {
serverId: intPkRef().primaryKey().references(() => servers.id, { onDelete: "cascade" }),
serverUpdatedAt: ts("server_updated_at").notNull(),
lastAttemptAt: ts("last_attempt_at").notNull(),
lastSucceededAt: ts("last_succeeded_at"),
status: text("status").notNull(),
error: text("error"),
})
export const uptimeProbes = pgTable("uptime_probes", {
id: text("id").primaryKey(),
srcServerId: bigint("src_server_id", { mode: "number" }).notNull()
+5 -3
View File
@@ -12,8 +12,10 @@ assert.equal(encodeTrafficFlags(true, true), 3)
assert.equal(encodeTrafficFlags(false, false), 0)
assert.equal(trafficFlagRunning(1), true)
assert.equal(trafficFlagDisabled(1), false)
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false })
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false })
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false })
assert.deepEqual(decodeTrafficSampleFlags(1), { running: true, disabled: false, countersKnown: false })
assert.deepEqual(decodeTrafficSampleFlags(0), { running: false, disabled: false, countersKnown: false })
assert.deepEqual(decodeTrafficSampleFlags(undefined), { running: false, disabled: false, countersKnown: false })
assert.equal(encodeTrafficFlags(true,false,true),5)
assert.deepEqual(decodeTrafficSampleFlags(5), {running:true,disabled:false,countersKnown:true})
console.log("traffic-flags.test.ts: ok")
+5 -1
View File
@@ -1,8 +1,10 @@
export const TRAFFIC_FLAG_RUNNING = 1
export const TRAFFIC_FLAG_DISABLED = 2
export const TRAFFIC_FLAG_COUNTERS_KNOWN = 4
export function encodeTrafficFlags(running: boolean, disabled: boolean): number {
export function encodeTrafficFlags(running: boolean, disabled: boolean, countersKnown = false): number {
return (running ? TRAFFIC_FLAG_RUNNING : 0) | (disabled ? TRAFFIC_FLAG_DISABLED : 0)
| (countersKnown ? TRAFFIC_FLAG_COUNTERS_KNOWN : 0)
}
export function trafficFlagRunning(flags: number | null | undefined): boolean {
@@ -16,9 +18,11 @@ export function trafficFlagDisabled(flags: number | null | undefined): boolean {
export function decodeTrafficSampleFlags(flags: number | null | undefined): {
running: boolean
disabled: boolean
countersKnown: boolean
} {
return {
running: trafficFlagRunning(flags),
disabled: trafficFlagDisabled(flags),
countersKnown: ((Number(flags) || 0) & TRAFFIC_FLAG_COUNTERS_KNOWN) !== 0,
}
}
+30 -36
View File
@@ -5,32 +5,28 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { serverSnapshots, servers } from "../db/schema.js"
import {
collectTrafficOnce,
getTrafficCollectorState,
getTrafficSettings,
readServerSamplesInRange,
updateTrafficSettings,
} from "../services/traffic-collector.js"
import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js"
import { refreshScheduler } from "../services/scheduler.js"
import { refreshScheduler, runSchedulerJobNow } from "../services/scheduler.js"
import { appendEvent } from "../modules/events/service/events-service.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import { readTrafficCollectionContext } from "../services/traffic-collection-state.js"
import { applyTrafficFreshness } from "../services/traffic-freshness.js"
import {
bpsToMbps,
buildTrafficFromSamples,
type TrafficObservations,
isLoopbackName,
parseMonitorTraffic,
rateBpsFromDelta,
} from "../services/traffic-rate.js"
import {
buildBoundInterfaceTraffic,
buildUserTrafficList,
} from "../services/traffic-users.js"
type SnapshotRow = typeof serverSnapshots.$inferSelect
interface TrafficServerDto {
observations?:TrafficObservations
id: string
@@ -53,8 +49,8 @@ interface TrafficInterfaceDto {
name: string
running: boolean
disabled: boolean
rxNow: number
txNow: number
rxNow: number | null
txNow: number | null
}
const LIVE_TICK_MS = 1500
@@ -96,6 +92,7 @@ function buildServerTraffic(
rangeStartMs: number,
rangeEndMs: number,
onlyInterface?: string,
context?: Awaited<ReturnType<typeof readTrafficCollectionContext>>,
): TrafficServerDto {
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, onlyInterface,undefined,true)
return {
@@ -104,7 +101,7 @@ function buildServerTraffic(
site: s.site || "—",
country: s.country || "UN",
status,
observations:built.observations,
observations:built.observations && applyTrafficFreshness(built.observations, s, context?.settings, context?.states.get(s.id), rangeEndMs),
rxNow: built.rxNow,
txNow: built.txNow,
rxPeak: built.rxPeak,
@@ -122,23 +119,6 @@ async function snapshotStatus(serverId: number): Promise<TrafficServerDto["statu
return snap?.status === "offline" ? "offline" : (snap?.status === "online" ? "online" : "degraded")
}
function ifaceNowMbps(
prev: { rxBytes: number; txBytes: number; sampledAt: string } | undefined,
last: { rxBytes: number; txBytes: number; sampledAt: string; rxBps: number; txBps: number },
): { rxNow: number; txNow: number } {
if (!prev) {
return { rxNow: bpsToMbps(last.rxBps), txNow: bpsToMbps(last.txBps) }
}
const t0 = Date.parse(prev.sampledAt)
const t1 = Date.parse(last.sampledAt)
const rxBps = rateBpsFromDelta(prev.rxBytes, last.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, last.txBytes, t0, t1)
return {
rxNow: bpsToMbps(rxBps ?? last.rxBps),
txNow: bpsToMbps(txBps ?? last.txBps),
}
}
function writeSse(raw: NodeJS.WritableStream, event: string, data: unknown) {
raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
@@ -267,8 +247,17 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/traffic/collect-now", async (_req, reply) => {
try {
await collectTrafficOnce()
scheduleAlertEngineAfterDataCollectors()
const receipt = await runSchedulerJobNow("traffic")
if (!receipt.result || receipt.result.job !== "traffic") return reply.status(502).send({ ok: false, error: receipt.error ?? "Нет подтверждённого результата сбора" })
const result = receipt.result
if (result.skipped) return reply.status(409).send({ error: "Сбор трафика уже выполняется" })
const failures = result.servers.filter(s => !s.ok)
if (result.fatalError || failures.length) {
const error = result.fatalError ?? failures.map(s => `${s.name}: ${s.error ?? "Ошибка опроса"}`).join("; ")
await appendEvent({ level: "warning", eventType: "traffic.collect.manual.failed", sourceModule: "traffic",
title: "Сбор трафика завершён с ошибками", message: error })
return reply.status(502).send({ ok: false, error, failures, succeeded: result.servers.filter(s => s.ok).map(s => s.serverId) })
}
const updated = await getTrafficSettings()
await appendEvent({
level: "info",
@@ -287,6 +276,8 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
lastError: updated.lastError || null,
})
} catch (error) {
if (typeof error === "object" && error && "statusCode" in error && error.statusCode === 409)
return reply.status(409).send({ error: error instanceof Error ? error.message : "Сбор трафика уже выполняется" })
const msg = error instanceof Error ? error.message : String(error)
await appendEvent({
level: "critical",
@@ -306,9 +297,10 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const rangeStartMs = rangeEndMs - minutes * 60_000
const sinceIso = new Date(rangeStartMs).toISOString()
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const context = await readTrafficCollectionContext()
const data = await Promise.all(allServers.map(async (s): Promise<TrafficServerDto> => {
const rows = await readServerSamplesInRange(s.id, sinceIso)
return buildServerTraffic(s, await snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs)
return buildServerTraffic(s, await snapshotStatus(s.id), rows, rangeStartMs, rangeEndMs, undefined, context)
}))
return reply.send({ servers: data })
@@ -385,9 +377,10 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const sinceIso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const rows = await readServerSamplesInRange(serverId, sinceIso)
const context = await readTrafficCollectionContext(), end = Date.now()
const byIface = new Map<string, typeof rows>()
for (const r of rows) {
if (isLoopbackName(r.interfaceName)) continue
if (isLoopbackName(r.interfaceName) || r.peerPublicKey) continue
const arr = byIface.get(r.interfaceName) ?? []
arr.push(r)
byIface.set(r.interfaceName, arr)
@@ -395,19 +388,19 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
const interfaces: TrafficInterfaceDto[] = [...byIface.entries()].map(([name, arr]) => {
const sorted = arr.sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const last = sorted[sorted.length - 1]
const prev = sorted[sorted.length - 2]
if (!last) {
return { name, running: false, disabled: true, rxNow: 0, txNow: 0 }
}
const now = ifaceNowMbps(prev, last)
const built = buildTrafficFromSamples(sorted, Date.parse(sinceIso), end, name, undefined, true)
const observed = applyTrafficFreshness(built.observations!, server, context.settings, context.states.get(server.id), end)
return {
name,
running: Boolean(last.running),
disabled: Boolean(last.disabled),
rxNow: now.rxNow,
txNow: now.txNow,
rxNow: observed.rxNow ? built.rxNow : null,
txNow: observed.txNow ? built.txNow : null,
}
}).sort((a, b) => (b.rxNow + b.txNow) - (a.rxNow + a.txNow))
}).sort((a, b) => ((b.rxNow ?? -1) + (b.txNow ?? -1)) - ((a.rxNow ?? -1) + (a.txNow ?? -1)))
return reply.send({ interfaces })
})
@@ -432,6 +425,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => {
rangeStartMs,
rangeEndMs,
q.iface && q.iface !== "__all__" ? q.iface : undefined,
await readTrafficCollectionContext(),
)
return reply.send({ server: data })
})
@@ -4,6 +4,8 @@ import { collectGreSignalsFromRuns } from "./gre-signals.js"
import { readGreBgpRunSnapshots } from "../gre-bgp-snapshot-history.js"
import { collectSourceSignals, probePeriodMs, type CollectorRunRow, type CollectorSourceConfig } from "./collector-source-signals.js"
import type { SignalSnapshot } from "./types.js"
import type { TrafficAttempt } from "../traffic-freshness.js"
import { applyTrafficFreshness } from "../traffic-freshness.js"
export type CollectorRead = <T extends object>(query: string, params?: unknown[]) => Promise<T[]>
export type CollectorSourceSnapshot = Pick<SignalSnapshot, "sampledAt" | "servers" | "probes" | "trafficByServer">
@@ -18,7 +20,7 @@ async function readRuns(read: CollectorRead, job: string, limit: number, since?:
/** Read-only: do not seed defaults or load credentials while building alert facts. */
export async function readCollectorSourceSnapshot(clock: () => number = Date.now, read: CollectorRead = dbAll): Promise<CollectorSourceSnapshot> {
const now = clock()
const [servers, probes, uptime, rest, traffic] = await Promise.all([
const [servers, probes, uptime, rest, traffic, attempts] = await Promise.all([
read<CollectorSourceConfig["servers"][number]>("SELECT id, name, host, enabled, updated_at AS \"updatedAt\" FROM servers"),
read<CollectorSourceConfig["probes"][number]>(`SELECT id, name, target, src_server_id AS "srcServerId", src_interface AS "srcInterface",
enabled, interval_sec AS "intervalSec", updated_at AS "updatedAt" FROM uptime_probes`),
@@ -26,6 +28,8 @@ export async function readCollectorSourceSnapshot(clock: () => number = Date.now
interval_sec AS "intervalSec", probe_interval_sec AS "probeIntervalSec" FROM uptime_settings WHERE id = 1`),
read<NonNullable<CollectorSourceConfig["rest"]>>('SELECT enabled, interval_sec AS "intervalSec" FROM servers_api_ping_settings WHERE id = 1'),
read<NonNullable<CollectorSourceConfig["traffic"]>>('SELECT enabled, interval_sec AS "intervalSec" FROM traffic_settings WHERE id = 1'),
read<TrafficAttempt & {serverId:number}>(`SELECT server_id AS "serverId",server_updated_at AS "serverUpdatedAt",
last_attempt_at AS "lastAttemptAt",last_succeeded_at AS "lastSucceededAt",status,error FROM traffic_collection_state`),
])
const config: CollectorSourceConfig = { servers, probes, uptime: uptime[0], rest: rest[0], traffic: traffic[0] }
const periods = probes.filter(p => p.enabled).map(p => probePeriodMs(config, p)).filter(Number.isFinite)
@@ -36,9 +40,17 @@ export async function readCollectorSourceSnapshot(clock: () => number = Date.now
readRuns(read, "uptime_ping", 4096, new Date(now - probeWindowMs).toISOString()),
readRuns(read, "traffic", 8),
])
const facts = collectSourceSignals(config, { resources: resourceRuns, rest: restRuns, ping: pingRuns, traffic: trafficRuns }, now)
facts.trafficByServer = facts.trafficByServer.filter(signal => {
const server = servers.find(s => (s.name || s.host) === signal.serverName)
const attempt = server && attempts.find(a => a.serverId === Number(server.id))
if (!attempt) return true // Existing scheduler provenance remains readable until this device's first new attempt.
return !!server && applyTrafficFreshness({ rxNow: true, txNow: true, rxSeries: [], txSeries: [], sampledAt: signal.sampledAt },
{ enabled: server.enabled === true, updatedAt: server.updatedAt ?? "" }, traffic[0], attempt, now).currentState === "current"
})
return {
sampledAt: new Date(now).toISOString(),
...collectSourceSignals(config, { resources: resourceRuns, rest: restRuns, ping: pingRuns, traffic: trafficRuns }, now),
...facts,
}
}
@@ -55,9 +55,15 @@ test("partial backups, renew and alert errors are explicit while confirmed resul
assert.match(schedulerSnapshotError({ v: 1, job: "alert_engine", sampledAt: "now", rulesChecked: 1, standaloneFires: 0, groupFires: 0, skippedNoTelegram: false, errors: ["send failed"] })!, /send failed/)
})
test("valid offline/down observations are not collector errors", () => {
assert.equal(schedulerSnapshotError({ v: 1, job: "traffic", sampledAt: "now", servers: [{ serverId: 1, name: "router", host: "host", ok: false, error: "offline" }] }), null)
assert.equal(schedulerSnapshotError({ v: 1, job: "traffic", sampledAt: "now", servers: [{ serverId: 1, name: "router", host: "host", ok: true, sumRxMbps: 0, sumTxMbps: 0 }] }), null)
assert.equal(schedulerSnapshotError({ v: 1, job: "uptime_ping", sampledAt: "now", probes: [{ probeId: "probe", name: "probe", target: "host", srcServerId: 1, srcServerName: "router", srcInterface: "ether1", ok: false, rttMs: null, lossPct: 100, status: "down" }] }), null)
})
test("traffic command errors are limitations; partial measurements remain in the receipt",async()=>{
const snapshot={v:1 as const,job:"traffic" as const,sampledAt:"now",servers:[{serverId:1,name:"router",host:"host",ok:false,error:"401"},{serverId:2,name:"second",host:"second",ok:true,sumRxMbps:0,sumTxMbps:0}]}
const h=harness(snapshot as never),receipt=await runRecordedSchedulerJob(h.deps)
assert.equal(receipt.status,"error");assert.match(receipt.error!,/router: 401/)
assert.equal(receipt.result,snapshot);assert.equal(h.records.length,1);assert.equal(h.events[0].level,"warning")
})
test("skipped/not-due snapshots preserve their reason and do not announce completed work", async () => {
for (const snapshot of [backup({ due: false }), backup({ skipped: true })]) {
const h = harness(snapshot); const receipt = await runRecordedSchedulerJob(h.deps)
@@ -20,6 +20,9 @@ export function schedulerSnapshotError(snapshot: SchedulerRunSnapshot | null): s
if (!snapshot) return null
if ("fatalError" in snapshot && snapshot.fatalError) return snapshot.fatalError
const errors = "errors" in snapshot ? [...(snapshot.errors ?? [])] : []
if (snapshot.job === "traffic") for (const server of snapshot.servers) {
if (!server.ok) errors.push(`${server.name}: ${server.error ?? "Ошибка опроса трафика"}`)
}
if (snapshot.job === "uptime_speed") {
for (const run of snapshot.runs) if (!run.ok && run.error) errors.push(run.error)
}
@@ -0,0 +1,27 @@
import { and, eq, lte } from "drizzle-orm"
import { db } from "../db/index.js"
import { trafficCollectionState, trafficSettings, type Server } from "../db/schema.js"
export async function recordTrafficCollectionAttempt(server: Pick<Server, "id" | "updatedAt">,
status: "pending" | "ok" | "error", sampledAt: string, error?: string) {
if (status === "pending") {
const next = { serverId: server.id, serverUpdatedAt: server.updatedAt,
lastAttemptAt: sampledAt, status, error: null }
await db.insert(trafficCollectionState).values(next).onConflictDoUpdate({
target: trafficCollectionState.serverId, set: next,
setWhere: lte(trafficCollectionState.lastAttemptAt, sampledAt),
})
} else {
await db.update(trafficCollectionState).set({ status, error: error?.slice(0, 1024) ?? null,
...(status === "ok" ? { lastSucceededAt: sampledAt } : {}) }).where(and(
eq(trafficCollectionState.serverId, server.id), eq(trafficCollectionState.lastAttemptAt, sampledAt),
))
}
}
/** Read-only: does not create default settings or contact a device. */
export async function readTrafficCollectionContext() {
const [states, settings] = await Promise.all([db.select().from(trafficCollectionState),
db.select().from(trafficSettings).where(eq(trafficSettings.id, 1)).limit(1)])
return { states: new Map(states.map(s => [s.serverId, s])), settings: settings[0] }
}
+11 -5
View File
@@ -8,6 +8,7 @@ import { MikrotikClient } from "./mikrotik.js"
import { bpsToMbps, shouldIncludeIface } from "./traffic-rate.js"
import { observedTrafficRate, parseTrafficCounter } from "./traffic-observation.js"
import { rememberServerIfaces } from "./traffic-flow-ifindex.js"
import { recordTrafficCollectionAttempt } from "./traffic-collection-state.js"
interface RosIfaceTraffic {
".id"?: string
@@ -86,6 +87,7 @@ async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes
peerPublicKey: trafficSamples.peerPublicKey,
rxBytes: trafficSamples.rxBytes,
txBytes: trafficSamples.txBytes,
flags: trafficSamples.flags,
sampledAt: trafficSamples.sampledAt,
})
.from(trafficSamples)
@@ -93,7 +95,8 @@ async function readPreviousWave(serverId: number): Promise<Map<string, { rxBytes
eq(trafficSamples.serverId, serverId),
eq(trafficSamples.sampledAt, last.sampledAt),
))
return new Map(rows.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
return new Map(rows.filter(r => decodeTrafficSampleFlags(r.flags).countersKnown)
.map((r) => [`${r.interfaceName}\0${r.peerPublicKey ?? ""}`, r]))
}
export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
@@ -110,7 +113,6 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
collecting = true
const startedAt = Date.now()
const now = sampledAt
const settings = await getSettingsRow()
const snapshot: TrafficRunSnapshot = {
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "traffic",
@@ -119,11 +121,13 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
}
try {
await getSettingsRow()
const enabledServers = await db.select().from(servers).where(eq(servers.enabled, true))
for (const srv of enabledServers) {
const name = srv.name || srv.host
try {
await recordTrafficCollectionAttempt(srv, "pending", now)
const client = MikrotikClient.fromServer(srv)
const ifaces = await client.get<RosIfaceTraffic[]>("/interface")
rememberServerIfaces(srv.id, ifaces)
@@ -155,7 +159,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes,
rxBps,
txBps,
flags: encodeTrafficFlags(running, disabled),
flags: encodeTrafficFlags(running, disabled, true),
}
})
try {
@@ -184,7 +188,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
txBytes,
rxBps,
txBps,
flags: encodeTrafficFlags(running, disabled),
flags: encodeTrafficFlags(running, disabled, true),
})
}
} catch {
@@ -193,6 +197,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
if (rows.length > 0) {
await db.insert(trafficSamples).values(rows)
}
await recordTrafficCollectionAttempt(srv, "ok", now)
snapshot.servers.push({
serverId: srv.id,
name,
@@ -205,6 +210,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
} : {}),
})
} catch (err) {
await recordTrafficCollectionAttempt(srv, "error", now, err instanceof Error ? err.message : String(err))
snapshot.servers.push({
serverId: srv.id,
name,
@@ -218,7 +224,7 @@ export async function collectTrafficOnce(): Promise<TrafficRunSnapshot> {
await db.update(trafficSettings).set({
lastCollectedAt: now,
lastDurationMs: Date.now() - startedAt,
lastError: "",
lastError: snapshot.servers.filter(s => !s.ok).map(s => `${s.name}: ${s.error ?? "Ошибка опроса"}`).join("; "),
updatedAt: now,
}).where(eq(trafficSettings.id, 1))
} catch (e) {
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import { applyTrafficFreshness, mergeTrafficObservations } from "./traffic-freshness.js"
const now = Date.parse("2026-10-12T12:00:00Z"), iso = (offset=0) => new Date(now+offset).toISOString()
const observed = {rxNow:true,txNow:true,rxSeries:[false,true],txSeries:[false,true],sampledAt:iso()}
const server={enabled:true,updatedAt:iso(-3600_000)},settings={enabled:true,intervalSec:30}
const attempt={serverUpdatedAt:server.updatedAt,lastAttemptAt:iso(),lastSucceededAt:iso(),status:"ok",error:null}
const check=(s=server,c=settings,a:typeof attempt|undefined=attempt,o=observed,t=now)=>applyTrafficFreshness(o,s,c,a,t)
assert.equal(check().currentState,"current");assert.equal(check().rxNow,true)
for(const status of ["error","pending"]) {const r=check(server,settings,{...attempt,status});assert.equal(r.currentState,status);assert.equal(r.rxNow,false);assert.deepEqual(r.rxSeries,observed.rxSeries)}
assert.equal(applyTrafficFreshness(observed,server,settings,undefined,now).currentState,"unknown")
assert.equal(check({...server,updatedAt:iso(1)}).currentState,"unknown")
assert.equal(check({...server,enabled:false}).currentState,"disabled")
assert.equal(check(server,{...settings,enabled:false}).currentState,"disabled")
assert.equal(check(server,settings,attempt,observed,now+90_000).currentState,"current")
assert.equal(check(server,settings,attempt,observed,now+90_001).currentState,"stale")
assert.equal(check(server,settings,{...attempt,lastAttemptAt:iso(30_001)}).currentState,"stale")
assert.equal(check(server,settings,attempt,{...observed,sampledAt:iso(-1000)}).currentState,"unknown")
const mixed=mergeTrafficObservations([check(),check(server,settings,{...attempt,status:"error",error:"401"} as typeof attempt)])
assert.equal(mixed.rxNow,false);assert.equal(mixed.currentState,"error");assert.deepEqual(mixed.rxSeries,[false,true])
assert.equal(mergeTrafficObservations([]).rxNow,false)
console.log("traffic freshness/source barrier/merge tests ok")
+45
View File
@@ -0,0 +1,45 @@
import type { TrafficObservations } from "./traffic-rate.js"
export type TrafficCurrentState = "current" | "unknown" | "pending" | "error" | "stale" | "disabled"
export interface TrafficAttempt {
serverUpdatedAt: string | Date; lastAttemptAt: string | Date; lastSucceededAt: string | Date | null; status: string; error: string | null
}
export function mergeTrafficObservations(parts: TrafficObservations[]): TrafficObservations {
const oldest = (key: "sampledAt" | "lastAttemptAt" | "lastSucceededAt" | "expiresAt") => {
const times = parts.map(p => Date.parse(p[key] ?? ""))
return times.length && times.every(Number.isFinite) ? new Date(Math.min(...times)).toISOString() : null
}
const states: TrafficCurrentState[] = ["error", "pending", "stale", "disabled", "unknown"]
return { rxNow: !!parts.length && parts.every(p => p.rxNow), txNow: !!parts.length && parts.every(p => p.txNow),
rxSeries: Array.from({ length: parts[0]?.rxSeries.length ?? 60 }, (_, i) => !!parts.length && parts.every(p => p.rxSeries[i])),
txSeries: Array.from({ length: parts[0]?.txSeries.length ?? 60 }, (_, i) => !!parts.length && parts.every(p => p.txSeries[i])),
sampledAt: oldest("sampledAt"), expiresAt: oldest("expiresAt"), lastAttemptAt: oldest("lastAttemptAt"), lastSucceededAt: oldest("lastSucceededAt"),
currentState: parts.length ? states.find(state => parts.some(p => p.currentState === state)) ?? "current" : "unknown",
error: parts.find(p => p.error)?.error ?? null,
}
}
export function applyTrafficFreshness(observations: TrafficObservations,
server: { enabled: boolean; updatedAt: string | Date },
settings: { enabled: boolean; intervalSec: number } | undefined,
attempt: TrafficAttempt | undefined, now: number): TrafficObservations {
const period = Math.max(5, settings?.intervalSec ?? 30) * 1000
const time = (raw: string | Date | null | undefined) => raw instanceof Date ? raw.getTime() : Date.parse(raw ?? "")
const attemptMs = time(attempt?.lastAttemptAt), successMs = time(attempt?.lastSucceededAt)
let currentState: TrafficCurrentState = "unknown"
if (!server.enabled || settings?.enabled === false) currentState = "disabled"
else if (settings && attempt && time(attempt.serverUpdatedAt) === time(server.updatedAt)) {
if (!Number.isFinite(attemptMs) || attemptMs > now + period || now - attemptMs > 3 * period) currentState = "stale"
else if (attempt.status === "pending") currentState = "pending"
else if (attempt.status === "error") currentState = "error"
else if (attempt.status === "ok" && successMs === attemptMs &&
Date.parse(observations.sampledAt ?? "") === successMs && (observations.rxNow || observations.txNow)) currentState = "current"
}
return { ...observations,
expiresAt: Number.isFinite(attemptMs + 3 * period) && Math.abs(attemptMs + 3 * period) <= 8.64e15 ? new Date(attemptMs + 3 * period).toISOString() : null,
rxNow: currentState === "current" && observations.rxNow,
txNow: currentState === "current" && observations.txNow,
currentState, lastAttemptAt: Number.isFinite(attemptMs) ? new Date(attemptMs).toISOString() : null,
lastSucceededAt: Number.isFinite(successMs) ? new Date(successMs).toISOString() : null,
error: attempt?.error ?? null,
}
}
@@ -0,0 +1,61 @@
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 { recordTrafficCollectionAttempt } from "./traffic-collection-state.js"
import { buildSignalSnapshotFromCollectors } from "./alert-engine/signals-from-collectors.js"
import trafficRoutes from "../routes/traffic.js"
import { clearAlertCollectorHooksTimer } from "./alert-collector-hooks.js"
const database=process.env.MMAPP_OWN_SQL_DB
if(!database) console.log("traffic history 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-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}]))})
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()}
try {
await initDatabase()
const updatedAt=new Date(Date.now()-3_600_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,updatedAt])).rows[0].id)
await pool.query("INSERT INTO traffic_settings(id,enabled,interval_sec) VALUES(1,true,30) ON CONFLICT(id) DO UPDATE SET enabled=true,interval_sec=30")
await pool.query("INSERT INTO app_users(id,login,name) VALUES($1,$2,$3)",[userId,prefix+"login","History fixture"])
await pool.query("INSERT INTO user_interface_bindings(id,user_id,server_id,interface_name,interface_type) VALUES($1,$2,$3,'ether1','ether')",[prefix+"binding",userId,serverId])
for(const offset of[-60_000,-30_000])await pool.query("INSERT INTO traffic_samples(server_id,interface_name,sampled_at,rx_bytes,tx_bytes,flags) VALUES($1,'ether1',$2,1000,1000,1)",[serverId,new Date(Date.now()+offset).toISOString()])
const detail=async()=> (await get(`/traffic/servers/${serverId}?range=1h`)).server
let dto=await detail();assert.equal(dto.observations.rxNow,false);assert(dto.observations.rxSeries.every((v:boolean)=>!v));cases++
await collectTrafficOnce();dto=await detail();assert.equal(dto.observations.rxNow,false);cases++
await pool.query("DELETE FROM traffic_samples WHERE server_id=$1 AND (flags & 4)=0",[serverId])
await pool.query("UPDATE traffic_samples SET sampled_at=$1 WHERE server_id=$2",[new Date(Date.now()-30_000).toISOString(),serverId])
counter="3751000";const success=await collectTrafficOnce();dto=await detail();assert.equal(dto.observations.currentState,"current");assert.equal(dto.observations.rxNow,true);assert(dto.rxNow>0.9);cases++
await pool.query("INSERT INTO scheduler_runs(id,job_key,started_at,finished_at,status,result_json) VALUES($1,'traffic',$2,$3,'ok',$4::jsonb)",[prefix+"run",success.sampledAt,new Date().toISOString(),JSON.stringify(success)])
assert.equal((await buildSignalSnapshotFromCollectors()).trafficByServer.length,1);cases++
const succeeded=dto.observations.lastSucceededAt
deny=true;await collectTrafficOnce();dto=await detail();assert.equal(dto.observations.currentState,"error");assert.equal(dto.observations.rxNow,false);assert.equal(dto.observations.lastSucceededAt,succeeded);assert(dto.observations.rxSeries.some(Boolean));cases++
assert.deepEqual((await buildSignalSnapshotFromCollectors()).trafficByServer,[]);cases++
const users=(await get("/traffic/users?range=1h")).users, bound=(await get("/traffic/bound-interfaces?range=1h")).interfaces
assert.equal(users.find((u:{id:string})=>u.id===userId).observations.currentState,"error");assert.equal(bound.find((b:{userId:string})=>b.userId===userId).observations.rxNow,false);cases++
const interfaces=(await get(`/traffic/servers/${serverId}/interfaces`)).interfaces;assert.equal(interfaces[0].rxNow,null);cases++
const manual=await app.inject({method:"POST",url:"/traffic/collect-now"});assert.equal(manual.statusCode,502);assert.equal(manual.json().ok,false)
assert((await pool.query("SELECT status FROM scheduler_runs WHERE job_key='traffic' ORDER BY finished_at DESC LIMIT 1")).rows[0].status==="error");cases++
deny=false;counter="3751000";const recovery=await app.inject({method:"POST",url:"/traffic/collect-now"});assert.equal(recovery.statusCode,200);dto=await detail();assert.equal(dto.observations.currentState,"current");assert.equal(dto.rxNow,0);cases++
await pool.query("UPDATE traffic_collection_state SET last_attempt_at=$1,last_succeeded_at=$1 WHERE server_id=$2",[new Date(Date.now()-100_000).toISOString(),serverId]);dto=await detail();assert.equal(dto.observations.currentState,"stale");assert.equal(dto.observations.rxNow,false);assert(dto.observations.rxSeries.some(Boolean));cases++
await recordTrafficCollectionAttempt({id:serverId,updatedAt},"pending",new Date().toISOString());dto=await detail();assert.equal(dto.observations.currentState,"pending");cases++
await pool.query("UPDATE servers SET updated_at=$1 WHERE id=$2",[new Date(Date.now()+1).toISOString(),serverId]);dto=await detail();assert.equal(dto.observations.currentState,"unknown");cases++
await pool.query("UPDATE servers SET enabled=false WHERE id=$1",[serverId]);const before=requests;await collectTrafficOnce();assert.equal(requests,before);dto=await detail();assert.equal(dto.observations.currentState,"disabled");cases++
console.log(`traffic history owned SQL: ${cases} cases; legacy/current/failure/history/users/interfaces/manual/source/stale/pending/config/disabled passed`)
} finally {
clearAlertCollectorHooksTimer()
await app.close();await new Promise<void>(resolve=>http.close(()=>resolve()))
await pool.query("DELETE FROM app_users WHERE id=$1",[userId])
await pool.query("DELETE FROM scheduler_runs WHERE id LIKE $1",[prefix+"%"])
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()
}
}
@@ -45,6 +45,7 @@ const context = vm.createContext({
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 }] : [] }) },
db: {
@@ -15,6 +15,9 @@ export function observedTrafficRate(
rxBytes: number, txBytes: number, nowMs: number,
): { rxBps: number; txBps: number; measured: boolean } {
const prevMs = previous ? Date.parse(previous.sampledAt) : NaN
if (!previous || ![previous.rxBytes, previous.txBytes, rxBytes, txBytes].every(v => Number.isSafeInteger(v) && v >= 0)) {
return { rxBps: 0, txBps: 0, measured: false }
}
const rx = previous ? rateBpsFromDelta(previous.rxBytes, rxBytes, prevMs, nowMs) : null
const tx = previous ? rateBpsFromDelta(previous.txBytes, txBytes, prevMs, nowMs) : null
// Stored rates retain the legacy numeric shape; observed totals require both deltas.
+13 -13
View File
@@ -1,4 +1,5 @@
/** Чистые формулы трафика: дельты счётчиков, корзины series, monitor-traffic. */
import { mergeTrafficObservations } from "./traffic-freshness.js"
export const SERIES_POINTS = 60
@@ -12,6 +13,7 @@ export interface TrafficSampleLike {
txBps: number
running: boolean
disabled: boolean
countersKnown?: boolean
}
export interface RatePoint {
@@ -20,7 +22,8 @@ export interface RatePoint {
txMbps: number
}
export interface TrafficObservations {rxNow:boolean;txNow:boolean;rxSeries:boolean[];txSeries:boolean[];sampledAt:string|null}
export interface TrafficObservations {rxNow:boolean;txNow:boolean;rxSeries:boolean[];txSeries:boolean[];sampledAt:string|null;expiresAt?:string|null;
currentState?: import("./traffic-freshness.js").TrafficCurrentState;lastAttemptAt?:string|null;lastSucceededAt?:string|null;error?:string|null}
export interface BuiltTrafficSeries {
observations?:TrafficObservations
@@ -161,7 +164,7 @@ export function buildTrafficFromSamples(
continue
}
const sorted = [...arr].sort((a, b) => a.sampledAt.localeCompare(b.sampledAt))
const sorted = [...arr].sort((a, b) => Date.parse(a.sampledAt) - Date.parse(b.sampledAt))
const last = sorted[sorted.length - 1]
if (!last) continue
if (!allowList && (!last.running || last.disabled)) continue
@@ -169,14 +172,6 @@ export function buildTrafficFromSamples(
includedSeries++
if (last.running && !last.disabled) sessions += 1
const first = sorted[0]
if (first) {
const dRx = last.rxBytes - first.rxBytes
const dTx = last.txBytes - first.txBytes
rxBytesDelta += dRx >= 0 ? dRx : last.rxBytes
txBytesDelta += dTx >= 0 ? dTx : last.txBytes
}
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const cur = sorted[i]
@@ -185,8 +180,13 @@ 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 rxBps = rateBpsFromDelta(prev.rxBytes, cur.rxBytes, t0, t1)
const txBps = rateBpsFromDelta(prev.txBytes, cur.txBytes, t0, t1)
const countersKnown = prev.countersKnown !== false && cur.countersKnown !== false
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) {
if (rxBps !== null) rxBytesDelta += cur.rxBytes - prev.rxBytes
if (txBps !== null) txBytesDelta += cur.txBytes - prev.txBytes
}
if (rxBps == null && txBps == null) continue
const rxMbps = bpsToMbps(rxBps ?? 0)
const txMbps = bpsToMbps(txBps ?? 0)
@@ -269,7 +269,7 @@ export function mergeBuiltTraffic(parts: BuiltTrafficSeries[]): BuiltTrafficSeri
}
acc.rxTotalGiB = Number(acc.rxTotalGiB.toFixed(1))
acc.txTotalGiB = Number(acc.txTotalGiB.toFixed(1))
return acc
return parts.every(p => p.observations) ? { ...acc, observations: mergeTrafficObservations(parts.map(p => p.observations!)) } : acc
}
export function parseMonitorTraffic(
+12 -1
View File
@@ -8,8 +8,12 @@ import {
mergeBuiltTraffic,
type BuiltTrafficSeries,
} from "./traffic-rate.js"
import { readTrafficCollectionContext } from "./traffic-collection-state.js"
import { applyTrafficFreshness } from "./traffic-freshness.js"
import type { TrafficObservations } from "./traffic-rate.js"
export interface BoundIfaceTrafficDto {
observations?: TrafficObservations
id: string
bindingId: string
userId: string
@@ -36,6 +40,7 @@ export interface BoundIfaceTrafficDto {
}
export interface UserTrafficDto {
observations?: TrafficObservations
id: string
login: string
displayName: string
@@ -54,6 +59,7 @@ export interface UserTrafficDto {
function seriesFromBuilt(built: BuiltTrafficSeries) {
return {
observations: built.observations,
rxNow: built.rxNow,
txNow: built.txNow,
rxPeak: built.rxPeak,
@@ -74,6 +80,8 @@ async function serverStatus(serverId: number): Promise<"online" | "offline"> {
export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: number): Promise<UserTrafficDto[]> {
const sinceIso = new Date(rangeStartMs).toISOString()
const users = await listUsers()
const [context, allServers] = await Promise.all([readTrafficCollectionContext(), db.select().from(servers)])
const catalog = new Map(allServers.map(s => [s.id, s]))
const sampleCache = new Map<number, Awaited<ReturnType<typeof readServerSamplesInRange>>>()
return Promise.all(users.map(async (user) => {
@@ -87,7 +95,10 @@ export async function buildUserTrafficList(rangeStartMs: number, rangeEndMs: num
sampleCache.set(b.serverId, rows)
}
const peerKey = b.peerPublicKey ?? ""
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey)
const built = buildTrafficFromSamples(rows, rangeStartMs, rangeEndMs, b.interfaceName, peerKey, true)
const server = catalog.get(b.serverId)
if (built.observations) built.observations = applyTrafficFreshness(built.observations,
server ?? { enabled: false, updatedAt: "" }, context.settings, context.states.get(b.serverId), rangeEndMs)
parts.push(built)
const last = [...rows.filter((r) =>
r.interfaceName === b.interfaceName && (r.peerPublicKey ?? "") === peerKey,
+3 -3
View File
@@ -1,14 +1,14 @@
import { Fragment } from "react"
/** Compact RX/TX history shared by traffic cards. Gaps remain gaps. */
export function TrafficMiniChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) {
const valid = (value: number) => Number.isFinite(value) && value >= 0
export function TrafficMiniChart({ rx, tx, height = 44 }: { rx: (number|null)[]; tx: (number|null)[]; height?: number }) {
const valid = (value: number|null): value is number => value !== null && Number.isFinite(value) && value >= 0
const values = [...rx, ...tx].filter(valid)
if (!values.length) return <p className="text-xs text-muted-foreground">Нет истории трафика</p>
const width = 300
const count = Math.max(rx.length, tx.length)
const max = Math.max(...values, 1) * 1.1
const segments = (series: number[]) => {
const segments = (series: (number|null)[]) => {
const result: Array<Array<{ x: number; y: number }>> = []
let current: Array<{ x: number; y: number }> = []
series.forEach((value, index) => {
@@ -0,0 +1,18 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import type { TrafficObservationMeta } from "@/lib/traffic-observations"
export function TrafficObservationStatus({ observations: o, unknown }: { observations?: TrafficObservationMeta; unknown: boolean }) {
const labels = { error: "Ошибка опроса трафика", pending: "Опрос трафика выполняется", stale: "Измерения трафика устарели",
disabled: "Опрос трафика отключён", unknown: "Скорость пока не измерена", current: "Скорость пока не измерена" }
const warning = unknown || o?.currentState && o.currentState !== "current"
return <>
{warning && <Alert variant="warning" role="status" className="mb-3">
<AlertTitle>{labels[o?.currentState ?? "unknown"]}</AlertTitle>
<AlertDescription>{o?.error || "История сохранена. Для текущей скорости нужны достоверные свежие измерения."}</AlertDescription>
</Alert>}
{o?.lastAttemptAt && <p aria-label="Попытка опроса трафика" className="text-xs text-muted-foreground mb-3">
Последняя попытка: <time dateTime={o.lastAttemptAt}>{new Date(o.lastAttemptAt).toLocaleString("ru-RU")}</time>
{o.lastSucceededAt && <> · успешный сбор: <time dateTime={o.lastSucceededAt}>{new Date(o.lastSucceededAt).toLocaleString("ru-RU")}</time></>}
</p>}
</>
}
+32 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
IPsec78: реальный PSK responder established, mode-config198.18.78.10 и online=true подтверждены API; точные собственные объекты удалены. Сертификатный/DN online, late failure и history restore остаются открытыми. [Отчёт](ux-ui/acceptance/ipsec-mode-config-2026-10-09.json), [журнал](ux-ui-progress.md).
@@ -696,7 +696,7 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -2017,3 +2017,33 @@ Ping ownSQL173 расширен двумя controls: summary53ms900us сохра
[Live UI1440/320](ux-ui/acceptance/ping-duration-deployed-2026-10-12.json) прошёл: uptime overview200, unknown filter, no overflow/pageerrors. Actual production probes catalog пустой — эта smoke проверка не изображает main compound/error sample; они подтверждены отдельной ownSQL. Новых native Ping/config/password/Telegram операций не выполнялось. Пароли CHR не менялись. Исправления Ping173/176 и traffic174–175 теперь совместно доступны на тестовом backend; UI layout не менялся.
Следующий178 — read-only audit traffic history freshness при failed collection и legacy invalid-counter provenance; не объявлять старые неоднозначные строки исправленными по новому producer guard. Source unconfirmed/missing diagnostics, summary loss/count consistency, scale/multi-backend, физические WAN и внешние интеграции остаются условиями12.5. Журнал/план синхронизированы; локальный коммит без push. Новых блоков до следующего окна не начинать.
## 12.5.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12)
Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend.
Freshness reader read-only: enabled device/source, совпадение config fingerprint, state.ok и exact latest sample/success/attempt, TTL3periods (min5s), future tolerance1period. Error/pending/stale/disabled/config change/unknown маскируют current RX/TX; historical observed masks сохраняются. API серверов, клиентов и привязок получает observations/provenance; interface inventory больше не использует stored-rate fallback и не смешивает WG peer rows с interface counters. Source facts дополнительно учитывают новую attempt barrier, поэтому manual error/pending не оставляет старое traffic alarm активным. Для устройств без новой state пока читается прежняя scheduler provenance; первая новая попытка создаёт authoritative barrier. Не считать этот переход доказательством точности каждого старого snapshot.
В flags зарезервирован bit4 countersKnown, выставляется только новым strict collector. Старые/imported rows не переписаны и не объявлены достоверными; previous wave отбирает known counters. Исторический builder не строит deltas между legacy/unknown counters. Учтённые bytes суммируются по подтверждённым соседним дельтам; reset не добавляет произвольный новый counter как объём. Это lower-bound accounted volume, не утверждение полного покрытия периода. В UI подпись «Учтено RX/TX по замерам»; неизвестные скорости/пики/объёмы — —, chart gaps — null. Genuine measured0 сохраняется. Сортировка держит unknown после measured values. Dashboard existing decoder продолжает использовать masks.
Manual collect-now теперь через runSchedulerJobNow/общую запись результата и execution lock. Busy409; fatal/partial failures502 c failures/succeeded и сохранённым scheduler error; confirmed success200. Scheduler traffic ok=false теперь execution limitation, а genuine0 — success; valid offline/down других collectors не изменены. Ошибка setup больше не оставляет collecting=true навсегда: getSettingsRow находится внутри try/finally. Existing event/journal-unknown semantics сохранены.
Frontend projectTrafficObservations валидирует metadata/метрики, маскирует legacy numeric-only DTO, допускает RX/TX histories разной длины при корректных собственных masks. local10s clock переводит expired DTO в stale без новых сетевых запросов. Видны причина failed collection, последняя попытка и successful collection (не выдаётся за подтверждение measured rate). Offline object не скрывает сохранившуюся историю. ReUI Frame/installed Alert/KpiStatGrid/chart-23 adaptation и theme/base-nova сохранены; новые blocks/vendor components не устанавливались.
| Сценарий | Эталон и схема | Артефакт |
| --- | --- | --- |
| Ошибка опроса | Существующая ReUI Alert → current — → сохранённая RX/TX история | [ПК1440](ux-ui/screens/traffic-history-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-history-error-320-2026-10-12.png), API-fixture |
| Genuine0 → TTL | Подтверждённая0 → expiry → —, история остаётся | E2E clock/control; без clock-based refetch |
| Legacy counters | Нет происхождения → unknown → новая baseline → measured delta | Owned SQL/HTTP collector path |
Backend test:traffic-rate прошёл: existing formulas, producer body/in-memory boundaries, strict counter/rate guards, freshness/merge. Frontend projection/dashboard unit tests, traffic flags, scheduler11, application-source TypeScript/backend production build/ESLint passed (unused obsolete ifaceNow helper удалён). Первоначальная freshness unit ошиблась default-param undefined, исправлена на прямой missing-state вызов; backend type adapter учитывает PG Date/string. Scheduler regression assertion разделена: traffic command failure является ошибкой исполнения, не device offline observation.
35 unique E2E eventually passed: initial31=27pass/4fail выявили лишнее требование равной RX/TX длины и положительные fixtures без explicit observations; malformed negative gap fixture заменена правильным null. Delta14=13pass/1fail выявил shorthand-json fixture без metadata; исправлено, delta1 passed. Остальные изменённые positive fixtures21 passed29.5s. Новые4 cases error1440/320/history retained even offline, true0/TTL/no refetch, numeric legacy unknown прошли. Screenshot-only scoped repeats2×2 для видимого warning/history внутри main scroller не считаются новыми сценариями; финальные изображения просмотрены. Existing8 user GRE/VXLAN dirty screenshots не тронуты. Full UI1260/backend24 не повторялись без необходимости.
Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history controls (legacy/first/current/error/old-alarm barrier/users/interfaces/manual failure/success/stale/pending/config/disabled). Real MikrotikClient к loopback HTTP simulator, real SQL + Fastify GET/POST; не physical CHR fault. Common test:alert-engine20075ms:26 source/history SQL/GRE8/actual stability. Второй runner имел старую command label в report; corrected по actual command/harness и asserts before public recording, профиль не подменён повторным traffic run. Own DBs mm_accept_166_1791809970201 иmm_accept_166_1791810266923/ограниченные roles/test containers удалены; отсутствие проверено каталогами/docker. Production secrets не копировались. [SQL](ux-ui/acceptance/traffic-history-sql-2026-10-12.json).
[Source448 backend+521 frontend](ux-ui/acceptance/traffic-history-source-2026-10-12.json)/[1032 compiled](ux-ui/acceptance/traffic-history-compiled-2026-10-12.json) совпали SHA256. Обе production builds прошли (frontend185s Next phase); frontend ux-history178 staged with old backend ux-rtt177 [interop](ux-ui/acceptance/traffic-history-interop-2026-10-12.json), затем backend ux-history178/version1.0.0-test.history178. [Runtime](ux-ui/acceptance/traffic-history-runtime-2026-10-12.json) healthy/running/verified imageIds; в.env менялись только tags, rollback ux-rtt177/ux-ping173 без копии секретного env. Migration0011 подтверждена main PG. Новый frontend следует перезагрузить: прежняя вкладка не получает новый truth reader автоматически. Additive storage не гарантирует truthful interpretation старым UI.
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-12
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
Этапы0–11 завершены в локальных границах журнала. История выполненных внешних блоков и доказательства сохранены в [журнале](ux-ui-stage-12.md) и [протоколе](ux-ui-external-acceptance.md). Реальная Telegram доставка, Portal SSO, multi-instance и оставшиеся профильные сетевые ограничения открыты.
@@ -371,6 +371,6 @@ flowchart LR
## 12.5.77 — основной блок IPsec (2026-10-09)
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+31 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,36 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
## 12.5.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12)
Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend.
Freshness reader read-only: enabled device/source, совпадение config fingerprint, state.ok и exact latest sample/success/attempt, TTL3periods (min5s), future tolerance1period. Error/pending/stale/disabled/config change/unknown маскируют current RX/TX; historical observed masks сохраняются. API серверов, клиентов и привязок получает observations/provenance; interface inventory больше не использует stored-rate fallback и не смешивает WG peer rows с interface counters. Source facts дополнительно учитывают новую attempt barrier, поэтому manual error/pending не оставляет старое traffic alarm активным. Для устройств без новой state пока читается прежняя scheduler provenance; первая новая попытка создаёт authoritative barrier. Не считать этот переход доказательством точности каждого старого snapshot.
В flags зарезервирован bit4 countersKnown, выставляется только новым strict collector. Старые/imported rows не переписаны и не объявлены достоверными; previous wave отбирает known counters. Исторический builder не строит deltas между legacy/unknown counters. Учтённые bytes суммируются по подтверждённым соседним дельтам; reset не добавляет произвольный новый counter как объём. Это lower-bound accounted volume, не утверждение полного покрытия периода. В UI подпись «Учтено RX/TX по замерам»; неизвестные скорости/пики/объёмы — —, chart gaps — null. Genuine measured0 сохраняется. Сортировка держит unknown после measured values. Dashboard existing decoder продолжает использовать masks.
Manual collect-now теперь через runSchedulerJobNow/общую запись результата и execution lock. Busy409; fatal/partial failures502 c failures/succeeded и сохранённым scheduler error; confirmed success200. Scheduler traffic ok=false теперь execution limitation, а genuine0 — success; valid offline/down других collectors не изменены. Ошибка setup больше не оставляет collecting=true навсегда: getSettingsRow находится внутри try/finally. Existing event/journal-unknown semantics сохранены.
Frontend projectTrafficObservations валидирует metadata/метрики, маскирует legacy numeric-only DTO, допускает RX/TX histories разной длины при корректных собственных masks. local10s clock переводит expired DTO в stale без новых сетевых запросов. Видны причина failed collection, последняя попытка и successful collection (не выдаётся за подтверждение measured rate). Offline object не скрывает сохранившуюся историю. ReUI Frame/installed Alert/KpiStatGrid/chart-23 adaptation и theme/base-nova сохранены; новые blocks/vendor components не устанавливались.
| Сценарий | Эталон и схема | Артефакт |
| --- | --- | --- |
| Ошибка опроса | Существующая ReUI Alert → current — → сохранённая RX/TX история | [ПК1440](ux-ui/screens/traffic-history-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-history-error-320-2026-10-12.png), API-fixture |
| Genuine0 → TTL | Подтверждённая0 → expiry → —, история остаётся | E2E clock/control; без clock-based refetch |
| Legacy counters | Нет происхождения → unknown → новая baseline → measured delta | Owned SQL/HTTP collector path |
Backend test:traffic-rate прошёл: existing formulas, producer body/in-memory boundaries, strict counter/rate guards, freshness/merge. Frontend projection/dashboard unit tests, traffic flags, scheduler11, application-source TypeScript/backend production build/ESLint passed (unused obsolete ifaceNow helper удалён). Первоначальная freshness unit ошиблась default-param undefined, исправлена на прямой missing-state вызов; backend type adapter учитывает PG Date/string. Scheduler regression assertion разделена: traffic command failure является ошибкой исполнения, не device offline observation.
35 unique E2E eventually passed: initial31=27pass/4fail выявили лишнее требование равной RX/TX длины и положительные fixtures без explicit observations; malformed negative gap fixture заменена правильным null. Delta14=13pass/1fail выявил shorthand-json fixture без metadata; исправлено, delta1 passed. Остальные изменённые positive fixtures21 passed29.5s. Новые4 cases error1440/320/history retained even offline, true0/TTL/no refetch, numeric legacy unknown прошли. Screenshot-only scoped repeats2×2 для видимого warning/history внутри main scroller не считаются новыми сценариями; финальные изображения просмотрены. Existing8 user GRE/VXLAN dirty screenshots не тронуты. Full UI1260/backend24 не повторялись без необходимости.
Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history controls (legacy/first/current/error/old-alarm barrier/users/interfaces/manual failure/success/stale/pending/config/disabled). Real MikrotikClient к loopback HTTP simulator, real SQL + Fastify GET/POST; не physical CHR fault. Common test:alert-engine20075ms:26 source/history SQL/GRE8/actual stability. Второй runner имел старую command label в report; corrected по actual command/harness и asserts before public recording, профиль не подменён повторным traffic run. Own DBs mm_accept_166_1791809970201 иmm_accept_166_1791810266923/ограниченные roles/test containers удалены; отсутствие проверено каталогами/docker. Production secrets не копировались. [SQL](ux-ui/acceptance/traffic-history-sql-2026-10-12.json).
[Source448 backend+521 frontend](ux-ui/acceptance/traffic-history-source-2026-10-12.json)/[1032 compiled](ux-ui/acceptance/traffic-history-compiled-2026-10-12.json) совпали SHA256. Обе production builds прошли (frontend185s Next phase); frontend ux-history178 staged with old backend ux-rtt177 [interop](ux-ui/acceptance/traffic-history-interop-2026-10-12.json), затем backend ux-history178/version1.0.0-test.history178. [Runtime](ux-ui/acceptance/traffic-history-runtime-2026-10-12.json) healthy/running/verified imageIds; в.env менялись только tags, rollback ux-rtt177/ux-ping173 без копии секретного env. Migration0011 подтверждена main PG. Новый frontend следует перезагрузить: прежняя вкладка не получает новый truth reader автоматически. Additive storage не гарантирует truthful interpretation старым UI.
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push.
## 12.5.177 — SQL-регрессия составного RTT и обновление стенда (2026-10-12)
Ping ownSQL173 расширен двумя controls: summary53ms900us сохраняется как RTT54ms/loss0; raw53bad без подтверждённых counts/loss сохраняется как unknown/null/error. Остальные8 controls сохранены:401/empty/manual/actual100%/zero/API/legacy exact provenance/disabled no outbound. Node22/PG18.6 полный test:probes прошёл24383ms,10 SQL cases без skip. Тесты используют настоящий MikrotikClient к локальному HTTP simulator и действующий collector/PG observations; физический RouterOS malformed response не создавался. Native replay2 выполнен локально176; в удалённом test image evidence file отсутствует и replay явно skipped, не выдан за повторную physical проверку. [SQL](ux-ui/acceptance/ping-duration-sql-2026-10-12.json).
+33 -3
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-12
Это актуальная матрица после 12.5.177. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Это актуальная матрица после 12.5.178. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-rtt177`, frontend `ux-ping173`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-history178`, frontend `ux-history178`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
| Раздел | Подтверждено | Осталось / условие завершения |
| --- | --- | --- |
@@ -18,7 +18,7 @@
| Recursive routes | Production PUT/change/own history restore88, disabled guards, partial/unknown/applied outcomes, live формы/контекст | Автоматический rollback/retry, durable receipts и несколько экземпляров отсутствуют; PUT не является idempotent noop. Полная произвольная legacy catalog миграция не принята |
| Diagnostics/terminal | Allowlist/native reads, Ping/trace/route/MTU/DNS, actual avg-rtt, disabled guards, JWT/live PC/mobile89; real default memory tail/log limits/API/UI133 | DNS использует backend resolver; MTU — ограниченная оценка, не гарантированный максимум. Stop ожидания не доказывает остановку RouterOS команды; arbitrary CLI и physical disconnect не приняты |
| Portal/интеграции | Локальные и isolated JWT guards; UI/API error states в границах журналов | Настоящий Portal issuer/login/отзыв прав; RIPE SDK two-IP/no-fetch passed140, native IPv6 ULA flow pipeline passed145 (IPv4 transport), public IPv6 WAN/local licensed MaxMind/S3/container/certificate jobs и Gitea delivery требуют отдельных тестовых endpoints/аккаунтов. Не отправлять сообщения и не публиковать релиз без соответствующего поручения |
| Общая приёмка | Локальная12.3.83; UI1244/context221/compiled984 parity139; профильные build/E2E/live проверки последующих блоков | Traffic initial live mock-id gate passed141; IPv6 special ranges/cache passed142; unknown RIPE coordinates passed143; IPv6 synthetic UDP/worker/SQL passed144; native CHR ULA/IPFIX/API/UI passed145; ICMPv6/IPv6 endpoint labels passed146; HTTP DTO validation passed147; scoped flow catalog/serverId guards passed148; strict monthly calendar passed149; Sessions labels passed150; application GRE static IPv6 lifecycle151 passed; WireGuard static IPv6 lifecycle152 и disabled SQL/API gates passed; OSPF/BFD boundary/warning153 passed; OSPFv3 routes154 accepted in recorded bounds; BGP IPv6 basic155 passed; bigint ID/SQL guards156 passed; общая UI1260/context221/backend24 regression157 passed; IPv6 BFD correlation158 profile/deployed passed; native OSPFv3/BFDv6 boundary/recovery159 passed; BGP IPv6/BFD withdrawal/recovery160 passed; selected BGP/alert unknown guards161 replay/profiles/deployed passed (SQL stability skipped); private4-byte ASN162 native/API/UI passed; duplicate BGP alert keys163 replay/profiles/deployed passed; nullable selected BGP164 replay/profiles/API/UI passed; collector165 parallel/current guards/read-only PG passed; positive partial/error SQL166/full24 with PG passed (optional network SQL not repeated); GRE/BGP freshness167/SQL8 passed; other source audit168 выявил identity/disabled/freshness gaps; локальный server/traffic identity guard169 passed без SQL/deploy, source/per-probe/freshness170/ownSQL16/compiled1008/deployed passed; source diagnostics171/42E2E/ownSQL metadata/live2 passed; monitoring history172/ownSQL26/compiled1020/deployed passed; Ping known/error173/ownHTTP-SQL8/85uniqueE2E/native4/4/deployed passed; traffic confidence174 baseline reproduced/local guard passed; owned SQL9/9358ms/deploy175/1024compiled/live smoke passed; legacy history/provenance open; compound RTT176 local/native replay2 passed, SQL10/deploy177/1024compiled/live smoke passed; traffic history178 audit 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; live incomplete/disable-stream179 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). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
@@ -932,3 +932,33 @@ Ping ownSQL173 расширен двумя controls: summary53ms900us сохра
[Live UI1440/320](ux-ui/acceptance/ping-duration-deployed-2026-10-12.json) прошёл: uptime overview200, unknown filter, no overflow/pageerrors. Actual production probes catalog пустой — эта smoke проверка не изображает main compound/error sample; они подтверждены отдельной ownSQL. Новых native Ping/config/password/Telegram операций не выполнялось. Пароли CHR не менялись. Исправления Ping173/176 и traffic174–175 теперь совместно доступны на тестовом backend; UI layout не менялся.
Следующий178 — read-only audit traffic history freshness при failed collection и legacy invalid-counter provenance; не объявлять старые неоднозначные строки исправленными по новому producer guard. Source unconfirmed/missing diagnostics, summary loss/count consistency, scale/multi-backend, физические WAN и внешние интеграции остаются условиями12.5. Журнал/план синхронизированы; локальный коммит без push. Новых блоков до следующего окна не начинать.
## 12.5.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12)
Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend.
Freshness reader read-only: enabled device/source, совпадение config fingerprint, state.ok и exact latest sample/success/attempt, TTL3periods (min5s), future tolerance1period. Error/pending/stale/disabled/config change/unknown маскируют current RX/TX; historical observed masks сохраняются. API серверов, клиентов и привязок получает observations/provenance; interface inventory больше не использует stored-rate fallback и не смешивает WG peer rows с interface counters. Source facts дополнительно учитывают новую attempt barrier, поэтому manual error/pending не оставляет старое traffic alarm активным. Для устройств без новой state пока читается прежняя scheduler provenance; первая новая попытка создаёт authoritative barrier. Не считать этот переход доказательством точности каждого старого snapshot.
В flags зарезервирован bit4 countersKnown, выставляется только новым strict collector. Старые/imported rows не переписаны и не объявлены достоверными; previous wave отбирает known counters. Исторический builder не строит deltas между legacy/unknown counters. Учтённые bytes суммируются по подтверждённым соседним дельтам; reset не добавляет произвольный новый counter как объём. Это lower-bound accounted volume, не утверждение полного покрытия периода. В UI подпись «Учтено RX/TX по замерам»; неизвестные скорости/пики/объёмы — —, chart gaps — null. Genuine measured0 сохраняется. Сортировка держит unknown после measured values. Dashboard existing decoder продолжает использовать masks.
Manual collect-now теперь через runSchedulerJobNow/общую запись результата и execution lock. Busy409; fatal/partial failures502 c failures/succeeded и сохранённым scheduler error; confirmed success200. Scheduler traffic ok=false теперь execution limitation, а genuine0 — success; valid offline/down других collectors не изменены. Ошибка setup больше не оставляет collecting=true навсегда: getSettingsRow находится внутри try/finally. Existing event/journal-unknown semantics сохранены.
Frontend projectTrafficObservations валидирует metadata/метрики, маскирует legacy numeric-only DTO, допускает RX/TX histories разной длины при корректных собственных masks. local10s clock переводит expired DTO в stale без новых сетевых запросов. Видны причина failed collection, последняя попытка и successful collection (не выдаётся за подтверждение measured rate). Offline object не скрывает сохранившуюся историю. ReUI Frame/installed Alert/KpiStatGrid/chart-23 adaptation и theme/base-nova сохранены; новые blocks/vendor components не устанавливались.
| Сценарий | Эталон и схема | Артефакт |
| --- | --- | --- |
| Ошибка опроса | Существующая ReUI Alert → current — → сохранённая RX/TX история | [ПК1440](ux-ui/screens/traffic-history-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-history-error-320-2026-10-12.png), API-fixture |
| Genuine0 → TTL | Подтверждённая0 → expiry → —, история остаётся | E2E clock/control; без clock-based refetch |
| Legacy counters | Нет происхождения → unknown → новая baseline → measured delta | Owned SQL/HTTP collector path |
Backend test:traffic-rate прошёл: existing formulas, producer body/in-memory boundaries, strict counter/rate guards, freshness/merge. Frontend projection/dashboard unit tests, traffic flags, scheduler11, application-source TypeScript/backend production build/ESLint passed (unused obsolete ifaceNow helper удалён). Первоначальная freshness unit ошиблась default-param undefined, исправлена на прямой missing-state вызов; backend type adapter учитывает PG Date/string. Scheduler regression assertion разделена: traffic command failure является ошибкой исполнения, не device offline observation.
35 unique E2E eventually passed: initial31=27pass/4fail выявили лишнее требование равной RX/TX длины и положительные fixtures без explicit observations; malformed negative gap fixture заменена правильным null. Delta14=13pass/1fail выявил shorthand-json fixture без metadata; исправлено, delta1 passed. Остальные изменённые positive fixtures21 passed29.5s. Новые4 cases error1440/320/history retained even offline, true0/TTL/no refetch, numeric legacy unknown прошли. Screenshot-only scoped repeats2×2 для видимого warning/history внутри main scroller не считаются новыми сценариями; финальные изображения просмотрены. Existing8 user GRE/VXLAN dirty screenshots не тронуты. Full UI1260/backend24 не повторялись без необходимости.
Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history controls (legacy/first/current/error/old-alarm barrier/users/interfaces/manual failure/success/stale/pending/config/disabled). Real MikrotikClient к loopback HTTP simulator, real SQL + Fastify GET/POST; не physical CHR fault. Common test:alert-engine20075ms:26 source/history SQL/GRE8/actual stability. Второй runner имел старую command label в report; corrected по actual command/harness и asserts before public recording, профиль не подменён повторным traffic run. Own DBs mm_accept_166_1791809970201 иmm_accept_166_1791810266923/ограниченные roles/test containers удалены; отсутствие проверено каталогами/docker. Production secrets не копировались. [SQL](ux-ui/acceptance/traffic-history-sql-2026-10-12.json).
[Source448 backend+521 frontend](ux-ui/acceptance/traffic-history-source-2026-10-12.json)/[1032 compiled](ux-ui/acceptance/traffic-history-compiled-2026-10-12.json) совпали SHA256. Обе production builds прошли (frontend185s Next phase); frontend ux-history178 staged with old backend ux-rtt177 [interop](ux-ui/acceptance/traffic-history-interop-2026-10-12.json), затем backend ux-history178/version1.0.0-test.history178. [Runtime](ux-ui/acceptance/traffic-history-runtime-2026-10-12.json) healthy/running/verified imageIds; в.env менялись только tags, rollback ux-rtt177/ux-ping173 без копии секретного env. Migration0011 подтверждена main PG. Новый frontend следует перезагрузить: прежняя вкладка не получает новый truth reader автоматически. Additive storage не гарантирует truthful interpretation старым UI.
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push.
+31 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-12: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Актуальное продолжение 2026-10-12: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3322,3 +3322,33 @@ Ping ownSQL173 расширен двумя controls: summary53ms900us сохра
[Live UI1440/320](ux-ui/acceptance/ping-duration-deployed-2026-10-12.json) прошёл: uptime overview200, unknown filter, no overflow/pageerrors. Actual production probes catalog пустой — эта smoke проверка не изображает main compound/error sample; они подтверждены отдельной ownSQL. Новых native Ping/config/password/Telegram операций не выполнялось. Пароли CHR не менялись. Исправления Ping173/176 и traffic174–175 теперь совместно доступны на тестовом backend; UI layout не менялся.
Следующий178 — read-only audit traffic history freshness при failed collection и legacy invalid-counter provenance; не объявлять старые неоднозначные строки исправленными по новому producer guard. Source unconfirmed/missing diagnostics, summary loss/count consistency, scale/multi-backend, физические WAN и внешние интеграции остаются условиями12.5. Журнал/план синхронизированы; локальный коммит без push. Новых блоков до следующего окна не начинать.
## 12.5.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12)
Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend.
Freshness reader read-only: enabled device/source, совпадение config fingerprint, state.ok и exact latest sample/success/attempt, TTL3periods (min5s), future tolerance1period. Error/pending/stale/disabled/config change/unknown маскируют current RX/TX; historical observed masks сохраняются. API серверов, клиентов и привязок получает observations/provenance; interface inventory больше не использует stored-rate fallback и не смешивает WG peer rows с interface counters. Source facts дополнительно учитывают новую attempt barrier, поэтому manual error/pending не оставляет старое traffic alarm активным. Для устройств без новой state пока читается прежняя scheduler provenance; первая новая попытка создаёт authoritative barrier. Не считать этот переход доказательством точности каждого старого snapshot.
В flags зарезервирован bit4 countersKnown, выставляется только новым strict collector. Старые/imported rows не переписаны и не объявлены достоверными; previous wave отбирает known counters. Исторический builder не строит deltas между legacy/unknown counters. Учтённые bytes суммируются по подтверждённым соседним дельтам; reset не добавляет произвольный новый counter как объём. Это lower-bound accounted volume, не утверждение полного покрытия периода. В UI подпись «Учтено RX/TX по замерам»; неизвестные скорости/пики/объёмы — —, chart gaps — null. Genuine measured0 сохраняется. Сортировка держит unknown после measured values. Dashboard existing decoder продолжает использовать masks.
Manual collect-now теперь через runSchedulerJobNow/общую запись результата и execution lock. Busy409; fatal/partial failures502 c failures/succeeded и сохранённым scheduler error; confirmed success200. Scheduler traffic ok=false теперь execution limitation, а genuine0 — success; valid offline/down других collectors не изменены. Ошибка setup больше не оставляет collecting=true навсегда: getSettingsRow находится внутри try/finally. Existing event/journal-unknown semantics сохранены.
Frontend projectTrafficObservations валидирует metadata/метрики, маскирует legacy numeric-only DTO, допускает RX/TX histories разной длины при корректных собственных masks. local10s clock переводит expired DTO в stale без новых сетевых запросов. Видны причина failed collection, последняя попытка и successful collection (не выдаётся за подтверждение measured rate). Offline object не скрывает сохранившуюся историю. ReUI Frame/installed Alert/KpiStatGrid/chart-23 adaptation и theme/base-nova сохранены; новые blocks/vendor components не устанавливались.
| Сценарий | Эталон и схема | Артефакт |
| --- | --- | --- |
| Ошибка опроса | Существующая ReUI Alert → current — → сохранённая RX/TX история | [ПК1440](ux-ui/screens/traffic-history-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-history-error-320-2026-10-12.png), API-fixture |
| Genuine0 → TTL | Подтверждённая0 → expiry → —, история остаётся | E2E clock/control; без clock-based refetch |
| Legacy counters | Нет происхождения → unknown → новая baseline → measured delta | Owned SQL/HTTP collector path |
Backend test:traffic-rate прошёл: existing formulas, producer body/in-memory boundaries, strict counter/rate guards, freshness/merge. Frontend projection/dashboard unit tests, traffic flags, scheduler11, application-source TypeScript/backend production build/ESLint passed (unused obsolete ifaceNow helper удалён). Первоначальная freshness unit ошиблась default-param undefined, исправлена на прямой missing-state вызов; backend type adapter учитывает PG Date/string. Scheduler regression assertion разделена: traffic command failure является ошибкой исполнения, не device offline observation.
35 unique E2E eventually passed: initial31=27pass/4fail выявили лишнее требование равной RX/TX длины и положительные fixtures без explicit observations; malformed negative gap fixture заменена правильным null. Delta14=13pass/1fail выявил shorthand-json fixture без metadata; исправлено, delta1 passed. Остальные изменённые positive fixtures21 passed29.5s. Новые4 cases error1440/320/history retained even offline, true0/TTL/no refetch, numeric legacy unknown прошли. Screenshot-only scoped repeats2×2 для видимого warning/history внутри main scroller не считаются новыми сценариями; финальные изображения просмотрены. Existing8 user GRE/VXLAN dirty screenshots не тронуты. Full UI1260/backend24 не повторялись без необходимости.
Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history controls (legacy/first/current/error/old-alarm barrier/users/interfaces/manual failure/success/stale/pending/config/disabled). Real MikrotikClient к loopback HTTP simulator, real SQL + Fastify GET/POST; не physical CHR fault. Common test:alert-engine20075ms:26 source/history SQL/GRE8/actual stability. Второй runner имел старую command label в report; corrected по actual command/harness и asserts before public recording, профиль не подменён повторным traffic run. Own DBs mm_accept_166_1791809970201 иmm_accept_166_1791810266923/ограниченные roles/test containers удалены; отсутствие проверено каталогами/docker. Production secrets не копировались. [SQL](ux-ui/acceptance/traffic-history-sql-2026-10-12.json).
[Source448 backend+521 frontend](ux-ui/acceptance/traffic-history-source-2026-10-12.json)/[1032 compiled](ux-ui/acceptance/traffic-history-compiled-2026-10-12.json) совпали SHA256. Обе production builds прошли (frontend185s Next phase); frontend ux-history178 staged with old backend ux-rtt177 [interop](ux-ui/acceptance/traffic-history-interop-2026-10-12.json), затем backend ux-history178/version1.0.0-test.history178. [Runtime](ux-ui/acceptance/traffic-history-runtime-2026-10-12.json) healthy/running/verified imageIds; в.env менялись только tags, rollback ux-rtt177/ux-ping173 без копии секретного env. Migration0011 подтверждена main PG. Новый frontend следует перезагрузить: прежняя вкладка не получает новый truth reader автоматически. Additive storage не гарантирует truthful interpretation старым UI.
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push.
+32 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10
Сервер161.104.46.222: завершён12.5.177 — parser176 проверен real HTTP client→owned PG→GET observations:10 SQL cases/test:probes24383ms, compound54/garbage unknown; ownDB/role/container удалены. Backend ux-rtt177 healthy/frontend ux-ping173,1024 compiled parity/2live UI passed. Следующий178 — аудит failed traffic attempts/history freshness и legacy counters;12.5 открыта.
Сервер161.104.46.222: завершён12.5.178 — traffic history/current freshness и legacy counter provenance, manual truth/journal, masks/TTL UI серверов/клиентов/привязок. OwnSQL9+14/15709ms иsource26+GRE8/20075ms,35 unique E2E eventually passed,build/types/ESLint/1032compiled parity. Both ux-history178 healthy/running, migration0011 applied/2native CHR GET/live UI2 passed; ownDBs/roles/containers removed. Следующий179 — live monitor-traffic complete payload и disable-after-stream-start guard;12.5 открыта.
| Параметр | Проверенное состояние |
| --- | --- |
@@ -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-rtt177 и mmapp-test/frontend:ux-ping173; запущены |
| Образы приложения | mmapp-test/backend:ux-history178 и mmapp-test/frontend:ux-history178; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -1748,3 +1748,33 @@ Ping ownSQL173 расширен двумя controls: summary53ms900us сохра
[Live UI1440/320](ux-ui/acceptance/ping-duration-deployed-2026-10-12.json) прошёл: uptime overview200, unknown filter, no overflow/pageerrors. Actual production probes catalog пустой — эта smoke проверка не изображает main compound/error sample; они подтверждены отдельной ownSQL. Новых native Ping/config/password/Telegram операций не выполнялось. Пароли CHR не менялись. Исправления Ping173/176 и traffic174–175 теперь совместно доступны на тестовом backend; UI layout не менялся.
Следующий178 — read-only audit traffic history freshness при failed collection и legacy invalid-counter provenance; не объявлять старые неоднозначные строки исправленными по новому producer guard. Source unconfirmed/missing diagnostics, summary loss/count consistency, scale/multi-backend, физические WAN и внешние интеграции остаются условиями12.5. Журнал/план синхронизированы; локальный коммит без push. Новых блоков до следующего окна не начинать.
## 12.5.178 — достоверная история трафика, ручной сбор и свежесть UI (2026-10-12)
Подтверждены source gaps: исторические deltas могли выдавать старую скорость как текущую после failed collection; manual collect-now игнорировал snapshot failures и публиковал success. Добавлена migration0011/traffic_collection_state (одна актуальная запись на server): pending до HTTP, ok/error после записи, lastAttemptAt/lastSucceededAt и конфигурационный updatedAt fingerprint. Pending/error не стирают successful history. Completion обновляет только свою lastAttemptAt; pending не переписывает более новую попытку. Это ограничивает гонки записей, но не является распределённым lock/полной приёмкой нескольких backend.
Freshness reader read-only: enabled device/source, совпадение config fingerprint, state.ok и exact latest sample/success/attempt, TTL3periods (min5s), future tolerance1period. Error/pending/stale/disabled/config change/unknown маскируют current RX/TX; historical observed masks сохраняются. API серверов, клиентов и привязок получает observations/provenance; interface inventory больше не использует stored-rate fallback и не смешивает WG peer rows с interface counters. Source facts дополнительно учитывают новую attempt barrier, поэтому manual error/pending не оставляет старое traffic alarm активным. Для устройств без новой state пока читается прежняя scheduler provenance; первая новая попытка создаёт authoritative barrier. Не считать этот переход доказательством точности каждого старого snapshot.
В flags зарезервирован bit4 countersKnown, выставляется только новым strict collector. Старые/imported rows не переписаны и не объявлены достоверными; previous wave отбирает known counters. Исторический builder не строит deltas между legacy/unknown counters. Учтённые bytes суммируются по подтверждённым соседним дельтам; reset не добавляет произвольный новый counter как объём. Это lower-bound accounted volume, не утверждение полного покрытия периода. В UI подпись «Учтено RX/TX по замерам»; неизвестные скорости/пики/объёмы — —, chart gaps — null. Genuine measured0 сохраняется. Сортировка держит unknown после measured values. Dashboard existing decoder продолжает использовать masks.
Manual collect-now теперь через runSchedulerJobNow/общую запись результата и execution lock. Busy409; fatal/partial failures502 c failures/succeeded и сохранённым scheduler error; confirmed success200. Scheduler traffic ok=false теперь execution limitation, а genuine0 — success; valid offline/down других collectors не изменены. Ошибка setup больше не оставляет collecting=true навсегда: getSettingsRow находится внутри try/finally. Existing event/journal-unknown semantics сохранены.
Frontend projectTrafficObservations валидирует metadata/метрики, маскирует legacy numeric-only DTO, допускает RX/TX histories разной длины при корректных собственных masks. local10s clock переводит expired DTO в stale без новых сетевых запросов. Видны причина failed collection, последняя попытка и successful collection (не выдаётся за подтверждение measured rate). Offline object не скрывает сохранившуюся историю. ReUI Frame/installed Alert/KpiStatGrid/chart-23 adaptation и theme/base-nova сохранены; новые blocks/vendor components не устанавливались.
| Сценарий | Эталон и схема | Артефакт |
| --- | --- | --- |
| Ошибка опроса | Существующая ReUI Alert → current — → сохранённая RX/TX история | [ПК1440](ux-ui/screens/traffic-history-error-1440-2026-10-12.png), [телефон320](ux-ui/screens/traffic-history-error-320-2026-10-12.png), API-fixture |
| Genuine0 → TTL | Подтверждённая0 → expiry → —, история остаётся | E2E clock/control; без clock-based refetch |
| Legacy counters | Нет происхождения → unknown → новая baseline → measured delta | Owned SQL/HTTP collector path |
Backend test:traffic-rate прошёл: existing formulas, producer body/in-memory boundaries, strict counter/rate guards, freshness/merge. Frontend projection/dashboard unit tests, traffic flags, scheduler11, application-source TypeScript/backend production build/ESLint passed (unused obsolete ifaceNow helper удалён). Первоначальная freshness unit ошиблась default-param undefined, исправлена на прямой missing-state вызов; backend type adapter учитывает PG Date/string. Scheduler regression assertion разделена: traffic command failure является ошибкой исполнения, не device offline observation.
35 unique E2E eventually passed: initial31=27pass/4fail выявили лишнее требование равной RX/TX длины и положительные fixtures без explicit observations; malformed negative gap fixture заменена правильным null. Delta14=13pass/1fail выявил shorthand-json fixture без metadata; исправлено, delta1 passed. Остальные изменённые positive fixtures21 passed29.5s. Новые4 cases error1440/320/history retained even offline, true0/TTL/no refetch, numeric legacy unknown прошли. Screenshot-only scoped repeats2×2 для видимого warning/history внутри main scroller не считаются новыми сценариями; финальные изображения просмотрены. Existing8 user GRE/VXLAN dirty screenshots не тронуты. Full UI1260/backend24 не повторялись без необходимости.
Owned PG18.6/Node22: test:traffic-rate15709ms,9 counter/source +14 history controls (legacy/first/current/error/old-alarm barrier/users/interfaces/manual failure/success/stale/pending/config/disabled). Real MikrotikClient к loopback HTTP simulator, real SQL + Fastify GET/POST; не physical CHR fault. Common test:alert-engine20075ms:26 source/history SQL/GRE8/actual stability. Второй runner имел старую command label в report; corrected по actual command/harness и asserts before public recording, профиль не подменён повторным traffic run. Own DBs mm_accept_166_1791809970201 иmm_accept_166_1791810266923/ограниченные roles/test containers удалены; отсутствие проверено каталогами/docker. Production secrets не копировались. [SQL](ux-ui/acceptance/traffic-history-sql-2026-10-12.json).
[Source448 backend+521 frontend](ux-ui/acceptance/traffic-history-source-2026-10-12.json)/[1032 compiled](ux-ui/acceptance/traffic-history-compiled-2026-10-12.json) совпали SHA256. Обе production builds прошли (frontend185s Next phase); frontend ux-history178 staged with old backend ux-rtt177 [interop](ux-ui/acceptance/traffic-history-interop-2026-10-12.json), затем backend ux-history178/version1.0.0-test.history178. [Runtime](ux-ui/acceptance/traffic-history-runtime-2026-10-12.json) healthy/running/verified imageIds; в.env менялись только tags, rollback ux-rtt177/ux-ping173 без копии секретного env. Migration0011 подтверждена main PG. Новый frontend следует перезагрузить: прежняя вкладка не получает новый truth reader автоматически. Additive storage не гарантирует truthful interpretation старым UI.
[Native read-only](ux-ui/acceptance/traffic-history-native-2026-10-12.json): оба CHR194.226.121.150/104.165.244.7, GET identity/interface200, по2 interfaces, counters valid;4 native GET, no routerConfigWrites/password changes/messages. Это не loss/speed/WAN proof. [Live smoke1440/320+3 APIs](ux-ui/acceptance/traffic-history-deployed-2026-10-12.json) подтверждает actual provenance/UI/overflow/pageerrors0; error/legacy branches — отдельно fixtures/ownSQL. Main DB read-only только для native checker; schema migration и штатный scheduler writes были частью deploy.
Следующий179 — inspect/fix live monitor-traffic empty/partial/malformed rates (текущий parseMonitorTraffic заменяет их нулями), per-interface fallback и disabled-after-stream-start polling. Не объявлять этот путь принятым по history guards. Peer collection completeness, long sparse intervals, interface recreation/identity, snapshot after restore, scale/multiple instances и external integrations остаются условиями12.5. Локальный коммит без push.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,42 @@
{
"date": "2026-10-12",
"block": "12.5.178",
"runtime": {
"backend": "ux-history178",
"frontend": "ux-history178"
},
"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,
"pageErrors": 0,
"noOverflow": true
},
{
"width": 320,
"trafficServers200": true,
"attemptProvenanceVisible": 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.178",
"newFrontend": "ux-history178",
"oldBackend": "ux-rtt177",
"readerAccepted": true,
"serverCount": 2,
"pageErrors": 0,
"bound": "Additive observed DTO reader compatibility; legacy numeric-only paths verified separately as unknown"
}
@@ -0,0 +1,29 @@
{
"date": "2026-10-12",
"block": "12.5.178",
"version": "1.0.0-test.history178",
"readOnlyDatabase": true,
"cases": [
{
"sourceId": 1,
"target": "194.226.121.150",
"identityRead": true,
"interfaceRead": true,
"interfaceCount": 2,
"countersValid": true
},
{
"sourceId": 2,
"target": "104.165.244.7",
"identityRead": true,
"interfaceRead": true,
"interfaceCount": 2,
"countersValid": true
}
],
"dbWrites": 0,
"routerConfigWrites": 0,
"messages": 0,
"migrationApplied": true,
"passed": true
}
@@ -0,0 +1,25 @@
{
"date": "2026-10-12",
"block": "12.5.178",
"secretEnvCopied": false,
"rollbackTags": {
"MMAPP_IMAGE_TAG": "ux-ping173",
"BACKEND_IMAGE_TAG": "ux-rtt177"
},
"services": {
"backend": {
"image": "mmapp-test/backend:ux-history178",
"imageId": "sha256:da0ff45a8f7e5b2fcfb98b6c17161c7dcb9776e9dadbd0770f35e95cbad89aaf",
"state": "running",
"health": "healthy",
"matchedVerifiedImage": true
},
"frontend": {
"image": "mmapp-test/frontend:ux-history178",
"imageId": "sha256:320a81a42a84af52fe200c9464d01105811d0d4a952654690471f14824cd1739",
"state": "running",
"health": null,
"matchedVerifiedImage": true
}
}
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-12",
"block": "12.5.178",
"matched": {
"backend": 448,
"frontend": 521
},
"different": [],
"missing": []
}
@@ -0,0 +1,49 @@
{
"date": "2026-10-12",
"block": "12.5.178",
"passed": true,
"profiles": [
{
"date": "2026-10-12",
"block": "12.5.178",
"database": "mm_accept_166_1791809970201",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:traffic-rate",
"passed": true,
"durationMs": 15709,
"ownedTrafficSqlCases": 9,
"ownedHistorySqlCases": 14,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
},
{
"date": "2026-10-12",
"block": "12.5.178",
"database": "mm_accept_166_1791810266923",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:alert-engine",
"passed": true,
"durationMs": 20075,
"ownedSourceSqlCases": 26,
"ownedGreSqlRows": 8,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+4 -2
View File
@@ -1,5 +1,6 @@
/** Формат скорости: Кбит/с при < 1 Мбит/с, иначе Мбит/с / Гбит/с. */
export function fmtRate(v: number): string {
export function fmtRate(v: number | null | undefined): string {
if (v == null) return "—"
if (!Number.isFinite(v) || v <= 0) return "0 Кбит/с"
if (v >= 1000) return `${(v / 1000).toFixed(2)} Гбит/с`
if (v < 1) return `${Math.round(v * 1000)} Кбит/с`
@@ -8,7 +9,8 @@ export function fmtRate(v: number): string {
return `${Math.round(v)} Мбит/с`
}
export function fmtGB(v: number): string {
export function fmtGB(v: number | null | undefined): string {
if (v == null) return "—"
if (v >= 1000) return `${(v / 1000).toFixed(2)} ТБ`
return `${v.toFixed(1)} ГБ`
}
+11
View File
@@ -0,0 +1,11 @@
import assert from "node:assert/strict"
import { projectTrafficObservations } from "./traffic-observations.js"
const at=Date.parse("2026-10-12T12:00:00Z"),sampledAt=new Date(at).toISOString(),expiresAt=new Date(at+90_000).toISOString()
const row={rxNow:0,txNow:0,rxPeak:0,txPeak:0,rxTotal:0,txTotal:0,rxSeries:[0,0],txSeries:[0,0]}
const obs={rxNow:true,txNow:true,rxSeries:[false,true],txSeries:[false,true],sampledAt,currentState:"current",expiresAt}
assert.equal(projectTrafficObservations(row,at).rxNow,null)
const known=projectTrafficObservations({...row,observations:obs},at);assert.equal(known.rxNow,0);assert.deepEqual(known.rxSeries,[null,0])
const stale=projectTrafficObservations({...row,observations:obs},at+90_001);assert.equal(stale.rxNow,null);assert.equal(stale.observations?.currentState,"stale");assert.deepEqual(stale.rxSeries,[null,0])
assert.equal(projectTrafficObservations({...row,observations:{...obs,currentState:"error"}},at).rxNow,null)
for(const change of [{rxNow:-1},{observations:{...obs,sampledAt:"bad"}},{observations:{...obs,sampledAt:null}},{observations:{...obs,rxSeries:[]}}])assert.throws(()=>projectTrafficObservations({...row,...change},at))
console.log("traffic observations nullable/zero/gaps/expiry guards tests ok")
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod"
const timestamp = z.string().refine(v => Number.isFinite(Date.parse(v))).nullable()
const rate = z.number().finite().nonnegative().nullable()
const meta = z.object({ rxNow: z.boolean(), txNow: z.boolean(), rxSeries: z.array(z.boolean()), txSeries: z.array(z.boolean()),
sampledAt: timestamp, expiresAt: timestamp.optional(), lastAttemptAt: timestamp.optional(), lastSucceededAt: timestamp.optional(), error: z.string().nullable().optional(),
currentState: z.enum(["current", "unknown", "pending", "error", "stale", "disabled"]).optional() })
export type TrafficObservationMeta = z.infer<typeof meta>
const metrics = z.object({ rxNow: rate, txNow: rate, rxPeak: rate, txPeak: rate, rxTotal: rate, txTotal: rate,
rxSeries: z.array(rate), txSeries: z.array(rate), observations: meta.optional() }).refine(r =>
(!r.observations || r.observations.rxSeries.length === r.rxSeries.length && r.observations.txSeries.length === r.txSeries.length &&
(!(r.observations.rxNow || r.observations.txNow || r.observations.rxSeries.some(Boolean) || r.observations.txSeries.some(Boolean)) || r.observations.sampledAt !== null)))
/** Receipt of numeric legacy DTO values cannot establish an observed measurement. */
export function projectTrafficObservations(raw: unknown, now = Date.now()) {
const parsed = metrics.safeParse(raw)
if (!parsed.success) throw new Error("Некорректные измерения трафика")
const r = parsed.data, o = r.observations && { ...r.observations }
if (o && o.expiresAt && Date.parse(o.expiresAt) < now && o.currentState === "current") o.currentState = "stale"
const current = !o?.currentState || o.currentState === "current"
const rxSeries = r.rxSeries.map((v, i) => o?.rxSeries[i] ? v : null)
const txSeries = r.txSeries.map((v, i) => o?.txSeries[i] ? v : null)
return { observations: o, rxNow: current && o?.rxNow ? r.rxNow : null, txNow: current && o?.txNow ? r.txNow : null,
rxSeries, txSeries, rxPeak: rxSeries.some(v => v !== null) ? r.rxPeak : null,
txPeak: txSeries.some(v => v !== null) ? r.txPeak : null,
rxTotal: rxSeries.some(v => v !== null) ? r.rxTotal : null, txTotal: txSeries.some(v => v !== null) ? r.txTotal : null }
}
+6 -5
View File
@@ -1,3 +1,4 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { test, expect } from '@playwright/test'
for (const outcome of ['success', 'error'] as const) {
@@ -6,23 +7,23 @@ for (const outcome of ['success', 'error'] as const) {
const gate = new Promise<void>(resolve => { release = resolve })
const individual: string[] = []
const server = { id: '1', name: 'Подтверждённый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] }
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/health', route => route.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname
if (/^\/api\/traffic\/servers\/[^/]+/.test(path)) individual.push(path)
if (path === '/api/traffic/servers') {
await gate
if (outcome === 'error') return route.fulfill({ status: 503, json: { error: 'Каталог временно недоступен' } })
return route.fulfill({ json: { servers: [server] } })
if (outcome === 'error') return route.fulfill({ status: 503, json: observedTrafficFixturePayload({ error: 'Каталог временно недоступен' }) })
return route.fulfill({ json: observedTrafficFixturePayload({ servers: [server] }) })
}
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: {} })
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: observedTrafficFixturePayload({}) })
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/servers/1' ? { server }
: path === '/api/traffic/servers/1/interfaces' ? { interfaces: [] }
: path === '/api/traffic/users' ? { users: [] }
: path === '/api/traffic/bound-interfaces' ? { interfaces: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
return route.fulfill({ json: observedTrafficFixturePayload(json) })
})
const catalog = page.waitForRequest(r => new URL(r.url()).pathname === '/api/traffic/servers')
await page.goto('/traffic')
+7 -6
View File
@@ -1,18 +1,19 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) {
const control = { holdRange: '', failIface: false, series: undefined as Array<number | null> | undefined, txSeries: undefined as number[] | undefined, release: () => {} }
const gate = new Promise<void>(resolve => { control.release = resolve })
const server = { id: '1', name: 'Первый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] }
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/health', route => route.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: {} })
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: observedTrafficFixturePayload({}) })
if (path === '/api/traffic/servers/1') {
const range = url.searchParams.get('range'), iface = url.searchParams.get('iface')
if (control.holdRange && range === control.holdRange) await gate
if (control.failIface && iface === 'ether2') return route.fulfill({ status: 500, json: { error: 'Интерфейс временно недоступен' } })
return route.fulfill({ json: { server: { ...server, rxSeries: control.series ?? server.rxSeries, txSeries: control.txSeries ?? control.series ?? server.txSeries, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } } })
if (control.failIface && iface === 'ether2') return route.fulfill({ status: 500, json: observedTrafficFixturePayload({ error: 'Интерфейс временно недоступен' }) })
return route.fulfill({ json: observedTrafficFixturePayload({ server: { ...server, rxSeries: control.series ?? server.rxSeries, txSeries: control.txSeries ?? control.series ?? server.txSeries, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } }) })
}
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/servers' ? { servers: [{ ...server, rxSeries: control.series ?? server.rxSeries, txSeries: control.txSeries ?? control.series ?? server.txSeries }] }
@@ -20,7 +21,7 @@ async function setup(page: Page) {
: path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] }
: path === '/api/traffic/users' ? { users: [] }
: path === '/api/servers' ? [] : {}
await route.fulfill({ json })
await route.fulfill({ json: observedTrafficFixturePayload(json) })
})
await page.goto('/traffic')
await expect(page.getByRole('heading', { name: 'Детали 1h __all__', exact: true })).toBeVisible()
@@ -118,7 +119,7 @@ test('Карточка трафика: пустая история, одна т
await page.getByRole('button', { name: '15м', exact: true }).click()
await expect(card.locator('circle')).toHaveCount(1)
await expect(card.locator('circle')).toHaveAttribute('cx', '150')
control.series = [2, 3, -1, 4, 5]
control.series = [2, 3, null, 4, 5]
control.txSeries = [0, 0, 0, 0, 0]
await page.getByRole('button', { name: '4ч', exact: true }).click()
await expect(card.locator('[data-direction="rx"] polyline')).toHaveCount(2)
@@ -0,0 +1,55 @@
import { test, expect, type Page } from "@playwright/test"
const at = new Date("2026-10-12T12:00:00Z")
async function setup(page: Page, state: "error" | "current" | "legacy", status = "online") {
let calls = 0
const observations = { rxNow: state === "current", txNow: state === "current", rxSeries: [false, true], txSeries: [false, true],
sampledAt: at.toISOString(), lastAttemptAt: at.toISOString(), lastSucceededAt: at.toISOString(),
expiresAt: new Date(at.getTime()+90_000).toISOString(), currentState: state === "legacy" ? "unknown" : state,
error: state === "error" ? "CHR: HTTP 401 — опрос не выполнен" : null }
const server = { id: "1", name: "История CHR", site: "TEST", country: "RU", status,
rxNow: state === "current" ? 0 : 17, txNow: 0, rxPeak: 5, txPeak: 6, rxTotal: 1, txTotal: 2, sessions: 1,
rxSeries: [0,5], txSeries: [0,6], ...(state !== "legacy" ? {observations} : {}) }
await page.clock.install({time:at})
await page.route("**/health", r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**", r=> {
const path = new URL(r.request().url()).pathname
if(path.endsWith("/live")) return r.fulfill({status:503,json:{error:"Live недоступен"}})
if(path==="/api/traffic/servers")calls++
return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/traffic/servers"?{servers:[server]}:
path==="/api/traffic/servers/1"?{server}:path.endsWith("/interfaces")||path==="/api/traffic/bound-interfaces"?{interfaces:[]}:
path==="/api/traffic/users"?{users:[]}:path==="/api/servers"?[]:{}})
})
await page.goto("/traffic")
await expect(page.getByRole("heading",{name:"История CHR",exact:true})).toBeVisible()
return ()=>calls
}
for(const width of [1440,320]) test(`Ошибка опроса: история сохранена, текущая скорость неизвестна ${width}`,async({page})=>{
await page.setViewportSize({width,height:1000});await setup(page,"error","offline")
await expect(page.getByRole("status").filter({hasText:"Ошибка опроса трафика"})).toContainText("HTTP 401")
await expect(page.getByRole("region",{name:"История RX/TX"})).toBeVisible()
await expect(page.getByLabel("Скорость выбранного сервера")).not.toContainText("17.0")
await expect(page.getByLabel("Скорость выбранного сервера").getByText("—",{exact:true})).toHaveCount(2)
await expect(page.getByLabel("Попытка опроса трафика")).toBeVisible()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true)
await page.getByRole("status").filter({hasText:"Ошибка опроса трафика"}).scrollIntoViewIfNeeded()
await page.getByRole("status").filter({hasText:"Ошибка опроса трафика"}).evaluate(el => {
let parent = el.parentElement
while (parent && (!/auto|scroll/.test(getComputedStyle(parent).overflowY) || parent.scrollHeight <= parent.clientHeight)) parent = parent.parentElement
if (parent) parent.scrollTop += el.getBoundingClientRect().top - parent.getBoundingClientRect().top - 24
})
await page.screenshot({path:`docs/ux-ui/screens/traffic-history-error-${width}-2026-10-12.png`,fullPage:true})
})
test("Измеренный ноль сохраняется; по TTL скорость становится неизвестной без подмены истории",async({page})=>{
const calls=await setup(page,"current")
await expect(page.getByLabel("Скорость выбранного сервера").getByText("0 Кбит/с",{exact:true})).toHaveCount(2)
const before=calls();await page.clock.fastForward(100_001)
await expect(page.getByRole("status").filter({hasText:"Измерения трафика устарели"})).toBeVisible()
await expect(page.getByLabel("Скорость выбранного сервера").getByText("—",{exact:true})).toHaveCount(2)
await expect(page.getByRole("region",{name:"История RX/TX"})).toBeVisible();expect(calls()).toBe(before)
})
test("Legacy numeric DTO без происхождения не изображает измеренную историю",async({page})=>{
await setup(page,"legacy")
await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible()
await expect(page.getByRole("status").filter({hasText:"Скорость пока не измерена"})).toBeVisible()
await expect(page.getByLabel("Скорость выбранного сервера")).not.toContainText("17.0")
})
+4 -3
View File
@@ -1,3 +1,4 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { test, expect } from '@playwright/test'
for (const width of [1440, 320]) {
@@ -10,10 +11,10 @@ for (const width of [1440, 320]) {
{ src: '10.0.0.1', dst: '8.8.8.8', srcPort: 50002, dstPort: 443, internetPeer: '8.8.8.8', internetPeerPort: 443, proto: 6, protoName: 'TCP', application: 'HTTPS' },
].map(row => ({ ...row, serverId: '1', serverName: 'IPv6 exporter', clientIp: row.src, inIface: 'ether1', bps: 1000, bytes: 1000, packets: 10 }))
const stats = { bpsNow: 0, bytes: 4000, packets: 40, conversations: 4, uniqueSrc: 4, uniqueDst: 4, topCategory: 'Прочее', topProto: 'ICMPv6', rxSeries: [], txSeries: [], interfaces: [], ifaces: [], live: false, timeline: [], applications: [], categories: [], services: [], countries: [], asns: [], protocols: [], sources: [], destinations: [], conversationsList: rows, dedupApplied: false }
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/health', route => route.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: {} })
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: observedTrafficFixturePayload({}) })
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/flow/analytics' ? stats
: path === '/api/traffic/flow/exporters' ? { exporters: [{ id: '1', name: 'IPv6 exporter', subtitle: '', site: 'TEST', country: '', status: 'online', rxNow: 0, txNow: 0, sessions: 4, bytes: 4000, rxSeries: [], txSeries: [] }] }
@@ -23,7 +24,7 @@ for (const width of [1440, 320]) {
: path === '/api/traffic/flow/clients' ? { clients: [] }
: path.endsWith('/interfaces') ? { interfaces: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
return route.fulfill({ json: observedTrafficFixturePayload(json) })
})
await page.goto('/traffic')
await page.getByRole('group', { name: 'Разделы трафика' }).getByRole('button', { name: 'Потоки', exact: true }).click()
+7 -6
View File
@@ -1,22 +1,23 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) {
const control = { holdRange: '', failList: false, release: () => {} }
const gate = new Promise<void>(resolve => { control.release = resolve })
const server = { id: '1', name: 'Первый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] }
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/health', route => route.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()), path = url.pathname
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: {} })
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: observedTrafficFixturePayload({}) })
if (path === '/api/traffic/servers/1') {
const range = url.searchParams.get('range'), iface = url.searchParams.get('iface')
return route.fulfill({ json: { server: { ...server, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } } })
return route.fulfill({ json: observedTrafficFixturePayload({ server: { ...server, name: `Детали ${range} ${iface}`, rxNow: range === '24h' ? 24 : 15 } }) })
}
if (path === '/api/traffic/servers') {
const range = url.searchParams.get('range')
if (control.holdRange === range) await gate
if (control.failList) return route.fulfill({ status: 500, json: { error: 'Сводка временно недоступна' } })
return route.fulfill({ json: { servers: [{ ...server, name: 'Список ' + range }] } })
if (control.failList) return route.fulfill({ status: 500, json: observedTrafficFixturePayload({ error: 'Сводка временно недоступна' }) })
return route.fulfill({ json: observedTrafficFixturePayload({ servers: [{ ...server, name: 'Список ' + range }] }) })
}
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/servers' ? { servers: [server] }
@@ -24,7 +25,7 @@ async function setup(page: Page) {
: path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] }
: path === '/api/traffic/users' ? { users: [] }
: path === '/api/servers' ? [] : {}
await route.fulfill({ json })
await route.fulfill({ json: observedTrafficFixturePayload(json) })
})
await page.goto('/traffic')
await expect(page.getByRole('heading', { name: 'Детали 1h __all__', exact: true })).toBeVisible()
+4 -3
View File
@@ -1,15 +1,16 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { installOpenLiveStream, resumeLiveStream, sendLiveHeartbeat } from "./live-stream-fixture"
import { test, expect } from '@playwright/test'
for (const variant of ['closed', 'invalid', 'http-error', 'silent', 'awaiting'] as const) {
test(`Трафик: SSE ${variant} не выдаёт старое измерение за live`, async ({ page }) => {
const server = { id: '1', name: 'Тестовый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] }
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/health', route => route.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', route => {
const url = new URL(route.request().url())
const path = url.pathname
if (path.endsWith('/live')) {
if (variant === 'http-error' || url.searchParams.get('iface') === 'ether2') return route.fulfill({ status: 503, json: { error: 'Поток недоступен' } })
if (variant === 'http-error' || url.searchParams.get('iface') === 'ether2') return route.fulfill({ status: 503, json: observedTrafficFixturePayload({ error: 'Поток недоступен' }) })
const sample = { rxMbps: 17, txMbps: 19, at: '2026-09-23T12:00:00Z' }
const body = 'event: sample\ndata: ' + JSON.stringify(sample) + '\n\n' + (variant === 'invalid' ? 'event: sample\ndata: {"rxMbps":null,"txMbps":-1}\n\n' : '')
return route.fulfill({ contentType: 'text/event-stream', body })
@@ -21,7 +22,7 @@ for (const variant of ['closed', 'invalid', 'http-error', 'silent', 'awaiting']
: path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] }
: path === '/api/traffic/users' ? { users: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
return route.fulfill({ json: observedTrafficFixturePayload(json) })
})
if (variant === 'silent' || variant === 'awaiting') await installOpenLiveStream(page, '/api/traffic/servers/1/live', { rxMbps: 17, txMbps: 19, at: '2026-09-23T12:00:00Z' }, variant !== 'awaiting')
const received = new Date('2026-09-28T06:00:00Z')
+3 -2
View File
@@ -1,3 +1,4 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { test, expect, type Page } from '@playwright/test'
const longName = 'customer-with-a-very-long-unbroken-identifier-12345678901234567890'
@@ -5,7 +6,7 @@ async function setup(page: Page, empty = false) {
const metrics = { rxNow: 2, txNow: 3, rxPeak: 4, txPeak: 5, rxTotal: 100, txTotal: 200, rxSeries: [1, 2], txSeries: [2, 3] }
const iface = { ...metrics, id: 'i1', bindingId: 'b1', userId: 'u1', userLogin: longName, userName: longName, interfaceName: longName, interfaceType: 'gre', comment: longName, serverId: '1', serverName: longName, serverSite: 'TEST', serverCountry: 'RU', status: 'online' }
const user = { ...metrics, id: 'u1', login: longName, displayName: longName, role: 'viewer', interfaces: [iface] }
await page.route('**/health', r => r.fulfill({ json: { ok: true } }))
await page.route('**/health', r => r.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const json = path === '/api/auth/config' ? { required: false }
@@ -14,7 +15,7 @@ async function setup(page: Page, empty = false) {
: path === '/api/traffic/bound-interfaces' ? { interfaces: empty ? [] : [iface] }
: path.endsWith('/interfaces') ? { interfaces: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
return route.fulfill({ json: observedTrafficFixturePayload(json) })
})
await page.goto('/traffic')
}
+12
View File
@@ -0,0 +1,12 @@
/** Positive fixtures explicitly declare measured rates; legacy absence is tested separately. */
export function observedTrafficFixturePayload(value: unknown): unknown {
if (Array.isArray(value)) return value.map(observedTrafficFixturePayload)
if (!value || typeof value !== "object") return value
const r = Object.fromEntries(Object.entries(value).map(([key, v]) => [key, observedTrafficFixturePayload(v)]))
if ("rxNow" in r && "txNow" in r && "rxTotal" in r && "txTotal" in r && Array.isArray(r.rxSeries) && Array.isArray(r.txSeries) && !("observations" in r)) {
r.observations = { rxNow: true, txNow: true, sampledAt: "2026-10-12T12:00:00Z",
rxSeries: r.rxSeries.map(v => typeof v === "number" && Number.isFinite(v) && v >= 0),
txSeries: r.txSeries.map(v => typeof v === "number" && Number.isFinite(v) && v >= 0) }
}
return r
}
@@ -1,3 +1,4 @@
import { observedTrafficFixturePayload } from "./traffic-observation-fixture"
import { test, expect } from '@playwright/test'
for (const variant of ['missing-sessions', 'negative-series', 'monthly-missing', 'monthly-mismatch'] as const) {
@@ -9,7 +10,7 @@ for (const variant of ['missing-sessions', 'negative-series', 'monthly-missing',
const stats = { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', topCategory: 'Восстановлено', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: false }
const card = { id: '1', name: 'Экспортёр DTO', subtitle: '', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, sessions: 1, bytes: 200, rxSeries: [1], txSeries: [2] }
page.on('pageerror', error => errors.push(error.message))
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/health', route => route.fulfill({ json: observedTrafficFixturePayload({ ok: true }) }))
await page.route('**/api/**', route => {
const url = new URL(route.request().url()), path = url.pathname
if (path === '/api/traffic/flow/analytics' || path === '/api/traffic/flow/monthly') {
@@ -18,13 +19,13 @@ for (const variant of ['missing-sessions', 'negative-series', 'monthly-missing',
const month = url.searchParams.get('month')!
const data = bad && variant === 'monthly-missing' ? { month, bytes: 1500 }
: { month: bad && variant === 'monthly-mismatch' ? (month === '2026-01' ? '2026-02' : '2026-01') : month, bytes: 1500, countries: [], services: [], asns: [] }
return route.fulfill({ json: data })
return route.fulfill({ json: observedTrafficFixturePayload(data) })
}
const data = bad && variant === 'missing-sessions' ? { ...stats, conversationsList: undefined }
: bad && variant === 'negative-series' ? { ...stats, rxSeries: [-1] } : stats
return route.fulfill({ json: data })
return route.fulfill({ json: observedTrafficFixturePayload(data) })
}
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: {} })
if (path.endsWith('/live')) return route.fulfill({ status: 503, json: observedTrafficFixturePayload({}) })
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/flow/exporters' ? { exporters: [card] }
: path === '/api/traffic/flows' ? { exportersOnline: 1, bytesPerMin: 200, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', talkers: [], listenerBound: true }
@@ -33,7 +34,7 @@ for (const variant of ['missing-sessions', 'negative-series', 'monthly-missing',
: path === '/api/traffic/flow/clients' ? { clients: [] }
: path.endsWith('/interfaces') ? { interfaces: [] }
: path === '/api/servers' ? [{ id: 1, name: card.name, host: '127.0.0.1', port: 80, useSsl: false, verifySsl: false, username: 'admin', password: '', type: 'jump-host', site: 'TEST', country: 'RU', asn: '', comment: '', enabled: true, lanSubnet: '', wanUplinks: [], createdAt: '', updatedAt: '', status: null, latency: null, os: null, model: null, uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null }] : {}
return route.fulfill({ json })
return route.fulfill({ json: observedTrafficFixturePayload(json) })
})
await page.goto('/traffic')
await page.getByRole('group', { name: 'Разделы трафика' }).getByRole('button', { name: 'Потоки', exact: true }).click()