diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index b4630ff4..d5c6cebb 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -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 (
-

Получено за период

+

Учтено RX по замерам

- {rxTotal.toFixed(1)} ГБ + {fmtGB(rxTotal)}

-

Отправлено за период

+

Учтено TX по замерам

- {txTotal.toFixed(1)} ГБ + {fmtGB(txTotal)}

@@ -583,7 +597,8 @@ function ServerDetail({ {sel.site} - {sel.status === "offline" ? : } + +
{sel.serverSite} - {sel.status === "offline" ? : } +
} label="RX" value={fmtRate(sel.rxNow)} /> } 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("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) diff --git a/backend/drizzle/0011_traffic_collection_state.sql b/backend/drizzle/0011_traffic_collection_state.sql new file mode 100644 index 00000000..f5c5245f --- /dev/null +++ b/backend/drizzle/0011_traffic_collection_state.sql @@ -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 +); diff --git a/backend/package.json b/backend/package.json index e1268137..8f50dff9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 22807bd7..ab78f791 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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() diff --git a/backend/src/db/traffic-flags.test.ts b/backend/src/db/traffic-flags.test.ts index 1fd05e48..06db8ea3 100644 --- a/backend/src/db/traffic-flags.test.ts +++ b/backend/src/db/traffic-flags.test.ts @@ -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") diff --git a/backend/src/db/traffic-flags.ts b/backend/src/db/traffic-flags.ts index a0a813bc..4c1da1e5 100644 --- a/backend/src/db/traffic-flags.ts +++ b/backend/src/db/traffic-flags.ts @@ -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, } } diff --git a/backend/src/routes/traffic.ts b/backend/src/routes/traffic.ts index a8c71995..72ed2a74 100644 --- a/backend/src/routes/traffic.ts +++ b/backend/src/routes/traffic.ts @@ -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>, ): 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 { 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 => { 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() 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 }) }) diff --git a/backend/src/services/alert-engine/signals-from-collectors.ts b/backend/src/services/alert-engine/signals-from-collectors.ts index 12ecf3f2..b0353908 100644 --- a/backend/src/services/alert-engine/signals-from-collectors.ts +++ b/backend/src/services/alert-engine/signals-from-collectors.ts @@ -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 = (query: string, params?: unknown[]) => Promise export type CollectorSourceSnapshot = Pick @@ -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 { const now = clock() - const [servers, probes, uptime, rest, traffic] = await Promise.all([ + const [servers, probes, uptime, rest, traffic, attempts] = await Promise.all([ read("SELECT id, name, host, enabled, updated_at AS \"updatedAt\" FROM servers"), read(`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>('SELECT enabled, interval_sec AS "intervalSec" FROM servers_api_ping_settings WHERE id = 1'), read>('SELECT enabled, interval_sec AS "intervalSec" FROM traffic_settings WHERE id = 1'), + read(`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, } } diff --git a/backend/src/services/scheduler-recorded-run.test.ts b/backend/src/services/scheduler-recorded-run.test.ts index e08e16cc..6fa20b2c 100644 --- a/backend/src/services/scheduler-recorded-run.test.ts +++ b/backend/src/services/scheduler-recorded-run.test.ts @@ -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) diff --git a/backend/src/services/scheduler-recorded-run.ts b/backend/src/services/scheduler-recorded-run.ts index 68554200..4c9a1a13 100644 --- a/backend/src/services/scheduler-recorded-run.ts +++ b/backend/src/services/scheduler-recorded-run.ts @@ -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) } diff --git a/backend/src/services/traffic-collection-state.ts b/backend/src/services/traffic-collection-state.ts new file mode 100644 index 00000000..59758a6a --- /dev/null +++ b/backend/src/services/traffic-collection-state.ts @@ -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, + 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] } +} diff --git a/backend/src/services/traffic-collector.ts b/backend/src/services/traffic-collector.ts index 11e5f77e..19af9f12 100644 --- a/backend/src/services/traffic-collector.ts +++ b/backend/src/services/traffic-collector.ts @@ -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 [`${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 { @@ -110,7 +113,6 @@ export async function collectTrafficOnce(): Promise { 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 { } 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("/interface") rememberServerIfaces(srv.id, ifaces) @@ -155,7 +159,7 @@ export async function collectTrafficOnce(): Promise { txBytes, rxBps, txBps, - flags: encodeTrafficFlags(running, disabled), + flags: encodeTrafficFlags(running, disabled, true), } }) try { @@ -184,7 +188,7 @@ export async function collectTrafficOnce(): Promise { txBytes, rxBps, txBps, - flags: encodeTrafficFlags(running, disabled), + flags: encodeTrafficFlags(running, disabled, true), }) } } catch { @@ -193,6 +197,7 @@ export async function collectTrafficOnce(): Promise { 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 { } : {}), }) } 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 { 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) { diff --git a/backend/src/services/traffic-freshness.test.ts b/backend/src/services/traffic-freshness.test.ts new file mode 100644 index 00000000..a06dda20 --- /dev/null +++ b/backend/src/services/traffic-freshness.test.ts @@ -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") diff --git a/backend/src/services/traffic-freshness.ts b/backend/src/services/traffic-freshness.ts new file mode 100644 index 00000000..476332ee --- /dev/null +++ b/backend/src/services/traffic-freshness.ts @@ -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, + } +} diff --git a/backend/src/services/traffic-history-sql.test.ts b/backend/src/services/traffic-history-sql.test.ts new file mode 100644 index 00000000..82e9c557 --- /dev/null +++ b/backend/src/services/traffic-history-sql.test.ts @@ -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(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(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() + } +} diff --git a/backend/src/services/traffic-observation.test.ts b/backend/src/services/traffic-observation.test.ts index a84af8bd..d29e27d4 100644 --- a/backend/src/services/traffic-observation.test.ts +++ b/backend/src/services/traffic-observation.test.ts @@ -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: { diff --git a/backend/src/services/traffic-observation.ts b/backend/src/services/traffic-observation.ts index 6a67982a..b80e2020 100644 --- a/backend/src/services/traffic-observation.ts +++ b/backend/src/services/traffic-observation.ts @@ -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. diff --git a/backend/src/services/traffic-rate.ts b/backend/src/services/traffic-rate.ts index 32ed81af..1f9c2c58 100644 --- a/backend/src/services/traffic-rate.ts +++ b/backend/src/services/traffic-rate.ts @@ -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( diff --git a/backend/src/services/traffic-users.ts b/backend/src/services/traffic-users.ts index 79053d76..8ac48053 100644 --- a/backend/src/services/traffic-users.ts +++ b/backend/src/services/traffic-users.ts @@ -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 { 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>>() 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, diff --git a/components/reui-kit/traffic-mini-chart.tsx b/components/reui-kit/traffic-mini-chart.tsx index 4d7568c9..14ee2928 100644 --- a/components/reui-kit/traffic-mini-chart.tsx +++ b/components/reui-kit/traffic-mini-chart.tsx @@ -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

Нет истории трафика

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> = [] let current: Array<{ x: number; y: number }> = [] series.forEach((value, index) => { diff --git a/components/traffic/traffic-observation-status.tsx b/components/traffic/traffic-observation-status.tsx new file mode 100644 index 00000000..aeb646b3 --- /dev/null +++ b/components/traffic/traffic-observation-status.tsx @@ -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 && + {labels[o?.currentState ?? "unknown"]} + {o?.error || "История сохранена. Для текущей скорости нужны достоверные свежие измерения."} + } + {o?.lastAttemptAt &&

+ Последняя попытка: + {o.lastSucceededAt && <> · успешный сбор: } +

} + +} diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 2c71bb1e..bcfba366 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -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. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 7b0c5974..67066bf9 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 13a7b72f..db41fc4a 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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). diff --git a/docs/ux-ui-remaining-acceptance.md b/docs/ux-ui-remaining-acceptance.md index 2628ea73..c8a38b11 100644 --- a/docs/ux-ui-remaining-acceptance.md +++ b/docs/ux-ui-remaining-acceptance.md @@ -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. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 4a286f68..1c1007d1 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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. diff --git a/docs/ux-ui-test-server.md b/docs/ux-ui-test-server.md index 2d3e1267..1a9e2d36 100644 --- a/docs/ux-ui-test-server.md +++ b/docs/ux-ui-test-server.md @@ -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. diff --git a/docs/ux-ui/acceptance/traffic-history-compiled-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-compiled-2026-10-12.json new file mode 100644 index 00000000..0f2b14ec --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-compiled-2026-10-12.json @@ -0,0 +1,1042 @@ +{ + "date": "2026-10-12", + "block": "12.5.178", + "backendVersion": "1.0.0-test.history178", + "normalization": "CRLF to LF", + "matched": [ + "backend/dist/services/traffic-collection-state.js", + "backend/dist/services/traffic-collection-state.js.map", + "backend/dist/services/traffic-collection-state.d.ts", + "backend/dist/services/traffic-collection-state.d.ts.map", + "backend/dist/services/traffic-freshness.js", + "backend/dist/services/traffic-freshness.js.map", + "backend/dist/services/traffic-freshness.d.ts", + "backend/dist/services/traffic-freshness.d.ts.map", + "backend/dist/config.js", + "backend/dist/config.js.map", + "backend/dist/config.d.ts", + "backend/dist/config.d.ts.map", + "backend/dist/constants/ping-probe.js", + "backend/dist/constants/ping-probe.js.map", + "backend/dist/constants/ping-probe.d.ts", + "backend/dist/constants/ping-probe.d.ts.map", + "backend/dist/db/bootstrap.js", + "backend/dist/db/bootstrap.js.map", + "backend/dist/db/bootstrap.d.ts", + "backend/dist/db/bootstrap.d.ts.map", + "backend/dist/db/index.js", + "backend/dist/db/index.js.map", + "backend/dist/db/index.d.ts", + "backend/dist/db/index.d.ts.map", + "backend/dist/db/json.js", + "backend/dist/db/json.js.map", + "backend/dist/db/json.d.ts", + "backend/dist/db/json.d.ts.map", + "backend/dist/db/migrate.js", + "backend/dist/db/migrate.js.map", + "backend/dist/db/migrate.d.ts", + "backend/dist/db/migrate.d.ts.map", + "backend/dist/db/operation-lock.js", + "backend/dist/db/operation-lock.js.map", + "backend/dist/db/operation-lock.d.ts", + "backend/dist/db/operation-lock.d.ts.map", + "backend/dist/db/partitions.js", + "backend/dist/db/partitions.js.map", + "backend/dist/db/partitions.d.ts", + "backend/dist/db/partitions.d.ts.map", + "backend/dist/db/schema.js", + "backend/dist/db/schema.js.map", + "backend/dist/db/schema.d.ts", + "backend/dist/db/schema.d.ts.map", + "backend/dist/db/sql-bind.js", + "backend/dist/db/sql-bind.js.map", + "backend/dist/db/sql-bind.d.ts", + "backend/dist/db/sql-bind.d.ts.map", + "backend/dist/db/sqlite-import.js", + "backend/dist/db/sqlite-import.js.map", + "backend/dist/db/sqlite-import.d.ts", + "backend/dist/db/sqlite-import.d.ts.map", + "backend/dist/db/traffic-flags.js", + "backend/dist/db/traffic-flags.js.map", + "backend/dist/db/traffic-flags.d.ts", + "backend/dist/db/traffic-flags.d.ts.map", + "backend/dist/index.js", + "backend/dist/index.js.map", + "backend/dist/index.d.ts", + "backend/dist/index.d.ts.map", + "backend/dist/lib/permissions.js", + "backend/dist/lib/permissions.js.map", + "backend/dist/lib/permissions.d.ts", + "backend/dist/lib/permissions.d.ts.map", + "backend/dist/managed-markers.js", + "backend/dist/managed-markers.js.map", + "backend/dist/managed-markers.d.ts", + "backend/dist/managed-markers.d.ts.map", + "backend/dist/modules/events/repository/events-repository.js", + "backend/dist/modules/events/repository/events-repository.js.map", + "backend/dist/modules/events/repository/events-repository.d.ts", + "backend/dist/modules/events/repository/events-repository.d.ts.map", + "backend/dist/modules/events/service/events-service.js", + "backend/dist/modules/events/service/events-service.js.map", + "backend/dist/modules/events/service/events-service.d.ts", + "backend/dist/modules/events/service/events-service.d.ts.map", + "backend/dist/modules/servers/mapper/servers-mapper.js", + "backend/dist/modules/servers/mapper/servers-mapper.js.map", + "backend/dist/modules/servers/mapper/servers-mapper.d.ts", + "backend/dist/modules/servers/mapper/servers-mapper.d.ts.map", + "backend/dist/modules/servers/repository/servers-repository.js", + "backend/dist/modules/servers/repository/servers-repository.js.map", + "backend/dist/modules/servers/repository/servers-repository.d.ts", + "backend/dist/modules/servers/repository/servers-repository.d.ts.map", + "backend/dist/modules/servers/service/servers-service.js", + "backend/dist/modules/servers/service/servers-service.js.map", + "backend/dist/modules/servers/service/servers-service.d.ts", + "backend/dist/modules/servers/service/servers-service.d.ts.map", + "backend/dist/modules/users/iface-type.js", + "backend/dist/modules/users/iface-type.js.map", + "backend/dist/modules/users/iface-type.d.ts", + "backend/dist/modules/users/iface-type.d.ts.map", + "backend/dist/modules/users/interface-inventory.js", + "backend/dist/modules/users/interface-inventory.js.map", + "backend/dist/modules/users/interface-inventory.d.ts", + "backend/dist/modules/users/interface-inventory.d.ts.map", + "backend/dist/modules/users/peer-bind.js", + "backend/dist/modules/users/peer-bind.js.map", + "backend/dist/modules/users/peer-bind.d.ts", + "backend/dist/modules/users/peer-bind.d.ts.map", + "backend/dist/modules/users/repository/users-repository.js", + "backend/dist/modules/users/repository/users-repository.js.map", + "backend/dist/modules/users/repository/users-repository.d.ts", + "backend/dist/modules/users/repository/users-repository.d.ts.map", + "backend/dist/modules/users/service/users-service.js", + "backend/dist/modules/users/service/users-service.js.map", + "backend/dist/modules/users/service/users-service.d.ts", + "backend/dist/modules/users/service/users-service.d.ts.map", + "backend/dist/plugins/auth.js", + "backend/dist/plugins/auth.js.map", + "backend/dist/plugins/auth.d.ts", + "backend/dist/plugins/auth.d.ts.map", + "backend/dist/plugins/database-request-gate.js", + "backend/dist/plugins/database-request-gate.js.map", + "backend/dist/plugins/database-request-gate.d.ts", + "backend/dist/plugins/database-request-gate.d.ts.map", + "backend/dist/routes/alerts.js", + "backend/dist/routes/alerts.js.map", + "backend/dist/routes/alerts.d.ts", + "backend/dist/routes/alerts.d.ts.map", + "backend/dist/routes/backups.js", + "backend/dist/routes/backups.js.map", + "backend/dist/routes/backups.d.ts", + "backend/dist/routes/backups.d.ts.map", + "backend/dist/routes/bgp.js", + "backend/dist/routes/bgp.js.map", + "backend/dist/routes/bgp.d.ts", + "backend/dist/routes/bgp.d.ts.map", + "backend/dist/routes/certificates.js", + "backend/dist/routes/certificates.js.map", + "backend/dist/routes/certificates.d.ts", + "backend/dist/routes/certificates.d.ts.map", + "backend/dist/routes/containers.js", + "backend/dist/routes/containers.js.map", + "backend/dist/routes/containers.d.ts", + "backend/dist/routes/containers.d.ts.map", + "backend/dist/routes/events.js", + "backend/dist/routes/events.js.map", + "backend/dist/routes/events.d.ts", + "backend/dist/routes/events.d.ts.map", + "backend/dist/routes/evobgp-catalog.js", + "backend/dist/routes/evobgp-catalog.js.map", + "backend/dist/routes/evobgp-catalog.d.ts", + "backend/dist/routes/evobgp-catalog.d.ts.map", + "backend/dist/routes/evobgp-settings.js", + "backend/dist/routes/evobgp-settings.js.map", + "backend/dist/routes/evobgp-settings.d.ts", + "backend/dist/routes/evobgp-settings.d.ts.map", + "backend/dist/routes/evobgp.js", + "backend/dist/routes/evobgp.js.map", + "backend/dist/routes/evobgp.d.ts", + "backend/dist/routes/evobgp.d.ts.map", + "backend/dist/routes/exec.js", + "backend/dist/routes/exec.js.map", + "backend/dist/routes/exec.d.ts", + "backend/dist/routes/exec.d.ts.map", + "backend/dist/routes/filters.js", + "backend/dist/routes/filters.js.map", + "backend/dist/routes/filters.d.ts", + "backend/dist/routes/filters.d.ts.map", + "backend/dist/routes/firewall.js", + "backend/dist/routes/firewall.js.map", + "backend/dist/routes/firewall.d.ts", + "backend/dist/routes/firewall.d.ts.map", + "backend/dist/routes/geoip.js", + "backend/dist/routes/geoip.js.map", + "backend/dist/routes/geoip.d.ts", + "backend/dist/routes/geoip.d.ts.map", + "backend/dist/routes/gre.js", + "backend/dist/routes/gre.js.map", + "backend/dist/routes/gre.d.ts", + "backend/dist/routes/gre.d.ts.map", + "backend/dist/routes/internet-path.js", + "backend/dist/routes/internet-path.js.map", + "backend/dist/routes/internet-path.d.ts", + "backend/dist/routes/internet-path.d.ts.map", + "backend/dist/routes/ipsec.js", + "backend/dist/routes/ipsec.js.map", + "backend/dist/routes/ipsec.d.ts", + "backend/dist/routes/ipsec.d.ts.map", + "backend/dist/routes/network.js", + "backend/dist/routes/network.js.map", + "backend/dist/routes/network.d.ts", + "backend/dist/routes/network.d.ts.map", + "backend/dist/routes/ospf.js", + "backend/dist/routes/ospf.js.map", + "backend/dist/routes/ospf.d.ts", + "backend/dist/routes/ospf.d.ts.map", + "backend/dist/routes/ping-probes-write.js", + "backend/dist/routes/ping-probes-write.js.map", + "backend/dist/routes/ping-probes-write.d.ts", + "backend/dist/routes/ping-probes-write.d.ts.map", + "backend/dist/routes/probes.js", + "backend/dist/routes/probes.js.map", + "backend/dist/routes/probes.d.ts", + "backend/dist/routes/probes.d.ts.map", + "backend/dist/routes/recursive-routes.js", + "backend/dist/routes/recursive-routes.js.map", + "backend/dist/routes/recursive-routes.d.ts", + "backend/dist/routes/recursive-routes.d.ts.map", + "backend/dist/routes/scheduler.js", + "backend/dist/routes/scheduler.js.map", + "backend/dist/routes/scheduler.d.ts", + "backend/dist/routes/scheduler.d.ts.map", + "backend/dist/routes/servers-api-ping.js", + "backend/dist/routes/servers-api-ping.js.map", + "backend/dist/routes/servers-api-ping.d.ts", + "backend/dist/routes/servers-api-ping.d.ts.map", + "backend/dist/routes/servers.js", + "backend/dist/routes/servers.js.map", + "backend/dist/routes/servers.d.ts", + "backend/dist/routes/servers.d.ts.map", + "backend/dist/routes/sidebar-counts.js", + "backend/dist/routes/sidebar-counts.js.map", + "backend/dist/routes/sidebar-counts.d.ts", + "backend/dist/routes/sidebar-counts.d.ts.map", + "backend/dist/routes/speed-probes-write.js", + "backend/dist/routes/speed-probes-write.js.map", + "backend/dist/routes/speed-probes-write.d.ts", + "backend/dist/routes/speed-probes-write.d.ts.map", + "backend/dist/routes/speed-test.js", + "backend/dist/routes/speed-test.js.map", + "backend/dist/routes/speed-test.d.ts", + "backend/dist/routes/speed-test.d.ts.map", + "backend/dist/routes/statistics.js", + "backend/dist/routes/statistics.js.map", + "backend/dist/routes/statistics.d.ts", + "backend/dist/routes/statistics.d.ts.map", + "backend/dist/routes/system-database.js", + "backend/dist/routes/system-database.js.map", + "backend/dist/routes/system-database.d.ts", + "backend/dist/routes/system-database.d.ts.map", + "backend/dist/routes/traffic-flow-purge.js", + "backend/dist/routes/traffic-flow-purge.js.map", + "backend/dist/routes/traffic-flow-purge.d.ts", + "backend/dist/routes/traffic-flow-purge.d.ts.map", + "backend/dist/routes/traffic-flow-settings.js", + "backend/dist/routes/traffic-flow-settings.js.map", + "backend/dist/routes/traffic-flow-settings.d.ts", + "backend/dist/routes/traffic-flow-settings.d.ts.map", + "backend/dist/routes/traffic-flow.js", + "backend/dist/routes/traffic-flow.js.map", + "backend/dist/routes/traffic-flow.d.ts", + "backend/dist/routes/traffic-flow.d.ts.map", + "backend/dist/routes/traffic.js", + "backend/dist/routes/traffic.js.map", + "backend/dist/routes/traffic.d.ts", + "backend/dist/routes/traffic.d.ts.map", + "backend/dist/routes/uptime.js", + "backend/dist/routes/uptime.js.map", + "backend/dist/routes/uptime.d.ts", + "backend/dist/routes/uptime.d.ts.map", + "backend/dist/routes/users.js", + "backend/dist/routes/users.js.map", + "backend/dist/routes/users.d.ts", + "backend/dist/routes/users.d.ts.map", + "backend/dist/routes/vxlan.js", + "backend/dist/routes/vxlan.js.map", + "backend/dist/routes/vxlan.d.ts", + "backend/dist/routes/vxlan.d.ts.map", + "backend/dist/routes/wireguard.js", + "backend/dist/routes/wireguard.js.map", + "backend/dist/routes/wireguard.d.ts", + "backend/dist/routes/wireguard.d.ts.map", + "backend/dist/scripts/migrate-sqlite-to-pg.js", + "backend/dist/scripts/migrate-sqlite-to-pg.js.map", + "backend/dist/scripts/migrate-sqlite-to-pg.d.ts", + "backend/dist/scripts/migrate-sqlite-to-pg.d.ts.map", + "backend/dist/scripts/rebuild-flow-facts.js", + "backend/dist/scripts/rebuild-flow-facts.js.map", + "backend/dist/scripts/rebuild-flow-facts.d.ts", + "backend/dist/scripts/rebuild-flow-facts.d.ts.map", + "backend/dist/services/acme-cloudflare.js", + "backend/dist/services/acme-cloudflare.js.map", + "backend/dist/services/acme-cloudflare.d.ts", + "backend/dist/services/acme-cloudflare.d.ts.map", + "backend/dist/services/alert-collector-hooks.js", + "backend/dist/services/alert-collector-hooks.js.map", + "backend/dist/services/alert-collector-hooks.d.ts", + "backend/dist/services/alert-collector-hooks.d.ts.map", + "backend/dist/services/alert-engine/bgp-signals.js", + "backend/dist/services/alert-engine/bgp-signals.js.map", + "backend/dist/services/alert-engine/bgp-signals.d.ts", + "backend/dist/services/alert-engine/bgp-signals.d.ts.map", + "backend/dist/services/alert-engine/collector-context-reader.js", + "backend/dist/services/alert-engine/collector-context-reader.js.map", + "backend/dist/services/alert-engine/collector-context-reader.d.ts", + "backend/dist/services/alert-engine/collector-context-reader.d.ts.map", + "backend/dist/services/alert-engine/collector-source-signals.js", + "backend/dist/services/alert-engine/collector-source-signals.js.map", + "backend/dist/services/alert-engine/collector-source-signals.d.ts", + "backend/dist/services/alert-engine/collector-source-signals.d.ts.map", + "backend/dist/services/alert-engine/confirm-stability.js", + "backend/dist/services/alert-engine/confirm-stability.js.map", + "backend/dist/services/alert-engine/confirm-stability.d.ts", + "backend/dist/services/alert-engine/confirm-stability.d.ts.map", + "backend/dist/services/alert-engine/cooldown.js", + "backend/dist/services/alert-engine/cooldown.js.map", + "backend/dist/services/alert-engine/cooldown.d.ts", + "backend/dist/services/alert-engine/cooldown.d.ts.map", + "backend/dist/services/alert-engine/decision-engine.js", + "backend/dist/services/alert-engine/decision-engine.js.map", + "backend/dist/services/alert-engine/decision-engine.d.ts", + "backend/dist/services/alert-engine/decision-engine.d.ts.map", + "backend/dist/services/alert-engine/evaluate-rule.js", + "backend/dist/services/alert-engine/evaluate-rule.js.map", + "backend/dist/services/alert-engine/evaluate-rule.d.ts", + "backend/dist/services/alert-engine/evaluate-rule.d.ts.map", + "backend/dist/services/alert-engine/gre-signals.js", + "backend/dist/services/alert-engine/gre-signals.js.map", + "backend/dist/services/alert-engine/gre-signals.d.ts", + "backend/dist/services/alert-engine/gre-signals.d.ts.map", + "backend/dist/services/alert-engine/groups.js", + "backend/dist/services/alert-engine/groups.js.map", + "backend/dist/services/alert-engine/groups.d.ts", + "backend/dist/services/alert-engine/groups.d.ts.map", + "backend/dist/services/alert-engine/monitoring-history.js", + "backend/dist/services/alert-engine/monitoring-history.js.map", + "backend/dist/services/alert-engine/monitoring-history.d.ts", + "backend/dist/services/alert-engine/monitoring-history.d.ts.map", + "backend/dist/services/alert-engine/outbox-execution.js", + "backend/dist/services/alert-engine/outbox-execution.js.map", + "backend/dist/services/alert-engine/outbox-execution.d.ts", + "backend/dist/services/alert-engine/outbox-execution.d.ts.map", + "backend/dist/services/alert-engine/outbox.js", + "backend/dist/services/alert-engine/outbox.js.map", + "backend/dist/services/alert-engine/outbox.d.ts", + "backend/dist/services/alert-engine/outbox.d.ts.map", + "backend/dist/services/alert-engine/prev-live-store.js", + "backend/dist/services/alert-engine/prev-live-store.js.map", + "backend/dist/services/alert-engine/prev-live-store.d.ts", + "backend/dist/services/alert-engine/prev-live-store.d.ts.map", + "backend/dist/services/alert-engine/rule-matcher.js", + "backend/dist/services/alert-engine/rule-matcher.js.map", + "backend/dist/services/alert-engine/rule-matcher.d.ts", + "backend/dist/services/alert-engine/rule-matcher.d.ts.map", + "backend/dist/services/alert-engine/run-once.js", + "backend/dist/services/alert-engine/run-once.js.map", + "backend/dist/services/alert-engine/run-once.d.ts", + "backend/dist/services/alert-engine/run-once.d.ts.map", + "backend/dist/services/alert-engine/server-signal-identity.js", + "backend/dist/services/alert-engine/server-signal-identity.js.map", + "backend/dist/services/alert-engine/server-signal-identity.d.ts", + "backend/dist/services/alert-engine/server-signal-identity.d.ts.map", + "backend/dist/services/alert-engine/signal-ingestor.js", + "backend/dist/services/alert-engine/signal-ingestor.js.map", + "backend/dist/services/alert-engine/signal-ingestor.d.ts", + "backend/dist/services/alert-engine/signal-ingestor.d.ts.map", + "backend/dist/services/alert-engine/signals-from-collectors.js", + "backend/dist/services/alert-engine/signals-from-collectors.js.map", + "backend/dist/services/alert-engine/signals-from-collectors.d.ts", + "backend/dist/services/alert-engine/signals-from-collectors.d.ts.map", + "backend/dist/services/alert-engine/snapshot.js", + "backend/dist/services/alert-engine/snapshot.js.map", + "backend/dist/services/alert-engine/snapshot.d.ts", + "backend/dist/services/alert-engine/snapshot.d.ts.map", + "backend/dist/services/alert-engine/source-watermark.js", + "backend/dist/services/alert-engine/source-watermark.js.map", + "backend/dist/services/alert-engine/source-watermark.d.ts", + "backend/dist/services/alert-engine/source-watermark.d.ts.map", + "backend/dist/services/alert-engine/state-machine.js", + "backend/dist/services/alert-engine/state-machine.js.map", + "backend/dist/services/alert-engine/state-machine.d.ts", + "backend/dist/services/alert-engine/state-machine.d.ts.map", + "backend/dist/services/alert-engine/target-issues.js", + "backend/dist/services/alert-engine/target-issues.js.map", + "backend/dist/services/alert-engine/target-issues.d.ts", + "backend/dist/services/alert-engine/target-issues.d.ts.map", + "backend/dist/services/alert-engine/telegram-emoji.js", + "backend/dist/services/alert-engine/telegram-emoji.js.map", + "backend/dist/services/alert-engine/telegram-emoji.d.ts", + "backend/dist/services/alert-engine/telegram-emoji.d.ts.map", + "backend/dist/services/alert-engine/types.js", + "backend/dist/services/alert-engine/types.js.map", + "backend/dist/services/alert-engine/types.d.ts", + "backend/dist/services/alert-engine/types.d.ts.map", + "backend/dist/services/alerts-config-execution.js", + "backend/dist/services/alerts-config-execution.js.map", + "backend/dist/services/alerts-config-execution.d.ts", + "backend/dist/services/alerts-config-execution.d.ts.map", + "backend/dist/services/alerts-outbox-overview.js", + "backend/dist/services/alerts-outbox-overview.js.map", + "backend/dist/services/alerts-outbox-overview.d.ts", + "backend/dist/services/alerts-outbox-overview.d.ts.map", + "backend/dist/services/alerts-outbox-retry.js", + "backend/dist/services/alerts-outbox-retry.js.map", + "backend/dist/services/alerts-outbox-retry.d.ts", + "backend/dist/services/alerts-outbox-retry.d.ts.map", + "backend/dist/services/alerts-overview.js", + "backend/dist/services/alerts-overview.js.map", + "backend/dist/services/alerts-overview.d.ts", + "backend/dist/services/alerts-overview.d.ts.map", + "backend/dist/services/alerts-revision.js", + "backend/dist/services/alerts-revision.js.map", + "backend/dist/services/alerts-revision.d.ts", + "backend/dist/services/alerts-revision.d.ts.map", + "backend/dist/services/alerts-service.js", + "backend/dist/services/alerts-service.js.map", + "backend/dist/services/alerts-service.d.ts", + "backend/dist/services/alerts-service.d.ts.map", + "backend/dist/services/alerts-telegram-delivery.js", + "backend/dist/services/alerts-telegram-delivery.js.map", + "backend/dist/services/alerts-telegram-delivery.d.ts", + "backend/dist/services/alerts-telegram-delivery.d.ts.map", + "backend/dist/services/alerts-telegram-send.js", + "backend/dist/services/alerts-telegram-send.js.map", + "backend/dist/services/alerts-telegram-send.d.ts", + "backend/dist/services/alerts-telegram-send.d.ts.map", + "backend/dist/services/alerts-telegram-settings.js", + "backend/dist/services/alerts-telegram-settings.js.map", + "backend/dist/services/alerts-telegram-settings.d.ts", + "backend/dist/services/alerts-telegram-settings.d.ts.map", + "backend/dist/services/backup-deletion.js", + "backend/dist/services/backup-deletion.js.map", + "backend/dist/services/backup-deletion.d.ts", + "backend/dist/services/backup-deletion.d.ts.map", + "backend/dist/services/backup-dto.js", + "backend/dist/services/backup-dto.js.map", + "backend/dist/services/backup-dto.d.ts", + "backend/dist/services/backup-dto.d.ts.map", + "backend/dist/services/backup-job-execution.js", + "backend/dist/services/backup-job-execution.js.map", + "backend/dist/services/backup-job-execution.d.ts", + "backend/dist/services/backup-job-execution.d.ts.map", + "backend/dist/services/backup-prune.js", + "backend/dist/services/backup-prune.js.map", + "backend/dist/services/backup-prune.d.ts", + "backend/dist/services/backup-prune.d.ts.map", + "backend/dist/services/backup-restore.js", + "backend/dist/services/backup-restore.js.map", + "backend/dist/services/backup-restore.d.ts", + "backend/dist/services/backup-restore.d.ts.map", + "backend/dist/services/backup-scheduler-collector.js", + "backend/dist/services/backup-scheduler-collector.js.map", + "backend/dist/services/backup-scheduler-collector.d.ts", + "backend/dist/services/backup-scheduler-collector.d.ts.map", + "backend/dist/services/backup-scheduler-run.js", + "backend/dist/services/backup-scheduler-run.js.map", + "backend/dist/services/backup-scheduler-run.d.ts", + "backend/dist/services/backup-scheduler-run.d.ts.map", + "backend/dist/services/backup-service.js", + "backend/dist/services/backup-service.js.map", + "backend/dist/services/backup-service.d.ts", + "backend/dist/services/backup-service.d.ts.map", + "backend/dist/services/backup-storage-update.js", + "backend/dist/services/backup-storage-update.js.map", + "backend/dist/services/backup-storage-update.d.ts", + "backend/dist/services/backup-storage-update.d.ts.map", + "backend/dist/services/bandwidth-test-rates.js", + "backend/dist/services/bandwidth-test-rates.js.map", + "backend/dist/services/bandwidth-test-rates.d.ts", + "backend/dist/services/bandwidth-test-rates.d.ts.map", + "backend/dist/services/bandwidth-test-status.js", + "backend/dist/services/bandwidth-test-status.js.map", + "backend/dist/services/bandwidth-test-status.d.ts", + "backend/dist/services/bandwidth-test-status.d.ts.map", + "backend/dist/services/bgp-catalog-poll.js", + "backend/dist/services/bgp-catalog-poll.js.map", + "backend/dist/services/bgp-catalog-poll.d.ts", + "backend/dist/services/bgp-catalog-poll.d.ts.map", + "backend/dist/services/bgp-parse-sessions.js", + "backend/dist/services/bgp-parse-sessions.js.map", + "backend/dist/services/bgp-parse-sessions.d.ts", + "backend/dist/services/bgp-parse-sessions.d.ts.map", + "backend/dist/services/bgp-peers-live.js", + "backend/dist/services/bgp-peers-live.js.map", + "backend/dist/services/bgp-peers-live.d.ts", + "backend/dist/services/bgp-peers-live.d.ts.map", + "backend/dist/services/btest-node-locks.js", + "backend/dist/services/btest-node-locks.js.map", + "backend/dist/services/btest-node-locks.d.ts", + "backend/dist/services/btest-node-locks.d.ts.map", + "backend/dist/services/certificate-issue-dto.js", + "backend/dist/services/certificate-issue-dto.js.map", + "backend/dist/services/certificate-issue-dto.d.ts", + "backend/dist/services/certificate-issue-dto.d.ts.map", + "backend/dist/services/certificate-issue-execution.js", + "backend/dist/services/certificate-issue-execution.js.map", + "backend/dist/services/certificate-issue-execution.d.ts", + "backend/dist/services/certificate-issue-execution.d.ts.map", + "backend/dist/services/certificate-issue-request.js", + "backend/dist/services/certificate-issue-request.js.map", + "backend/dist/services/certificate-issue-request.d.ts", + "backend/dist/services/certificate-issue-request.d.ts.map", + "backend/dist/services/certificate-issue-runner.js", + "backend/dist/services/certificate-issue-runner.js.map", + "backend/dist/services/certificate-issue-runner.d.ts", + "backend/dist/services/certificate-issue-runner.d.ts.map", + "backend/dist/services/certificate-parse.js", + "backend/dist/services/certificate-parse.js.map", + "backend/dist/services/certificate-parse.d.ts", + "backend/dist/services/certificate-parse.d.ts.map", + "backend/dist/services/certificate-renew-collector.js", + "backend/dist/services/certificate-renew-collector.js.map", + "backend/dist/services/certificate-renew-collector.d.ts", + "backend/dist/services/certificate-renew-collector.d.ts.map", + "backend/dist/services/certificates-service.js", + "backend/dist/services/certificates-service.js.map", + "backend/dist/services/certificates-service.d.ts", + "backend/dist/services/certificates-service.d.ts.map", + "backend/dist/services/certificates-state.js", + "backend/dist/services/certificates-state.js.map", + "backend/dist/services/certificates-state.d.ts", + "backend/dist/services/certificates-state.d.ts.map", + "backend/dist/services/complete-count.js", + "backend/dist/services/complete-count.js.map", + "backend/dist/services/complete-count.d.ts", + "backend/dist/services/complete-count.d.ts.map", + "backend/dist/services/config-apply-plan.js", + "backend/dist/services/config-apply-plan.js.map", + "backend/dist/services/config-apply-plan.d.ts", + "backend/dist/services/config-apply-plan.d.ts.map", + "backend/dist/services/config-revisions.js", + "backend/dist/services/config-revisions.js.map", + "backend/dist/services/config-revisions.d.ts", + "backend/dist/services/config-revisions.d.ts.map", + "backend/dist/services/containers-live.js", + "backend/dist/services/containers-live.js.map", + "backend/dist/services/containers-live.d.ts", + "backend/dist/services/containers-live.d.ts.map", + "backend/dist/services/containers-state.js", + "backend/dist/services/containers-state.js.map", + "backend/dist/services/containers-state.d.ts", + "backend/dist/services/containers-state.d.ts.map", + "backend/dist/services/database-background-work.js", + "backend/dist/services/database-background-work.js.map", + "backend/dist/services/database-background-work.d.ts", + "backend/dist/services/database-background-work.d.ts.map", + "backend/dist/services/database-request-gate.js", + "backend/dist/services/database-request-gate.js.map", + "backend/dist/services/database-request-gate.d.ts", + "backend/dist/services/database-request-gate.d.ts.map", + "backend/dist/services/entity-snapshots.js", + "backend/dist/services/entity-snapshots.js.map", + "backend/dist/services/entity-snapshots.d.ts", + "backend/dist/services/entity-snapshots.d.ts.map", + "backend/dist/services/evobgp-catalog.js", + "backend/dist/services/evobgp-catalog.js.map", + "backend/dist/services/evobgp-catalog.d.ts", + "backend/dist/services/evobgp-catalog.d.ts.map", + "backend/dist/services/firewall-live.js", + "backend/dist/services/firewall-live.js.map", + "backend/dist/services/firewall-live.d.ts", + "backend/dist/services/firewall-live.d.ts.map", + "backend/dist/services/firewall-poll.js", + "backend/dist/services/firewall-poll.js.map", + "backend/dist/services/firewall-poll.d.ts", + "backend/dist/services/firewall-poll.d.ts.map", + "backend/dist/services/geoip-settings.js", + "backend/dist/services/geoip-settings.js.map", + "backend/dist/services/geoip-settings.d.ts", + "backend/dist/services/geoip-settings.d.ts.map", + "backend/dist/services/geoip-update-collector.js", + "backend/dist/services/geoip-update-collector.js.map", + "backend/dist/services/geoip-update-collector.d.ts", + "backend/dist/services/geoip-update-collector.d.ts.map", + "backend/dist/services/gre-bgp-snapshot-collector.js", + "backend/dist/services/gre-bgp-snapshot-collector.js.map", + "backend/dist/services/gre-bgp-snapshot-collector.d.ts", + "backend/dist/services/gre-bgp-snapshot-collector.d.ts.map", + "backend/dist/services/gre-bgp-snapshot-history.js", + "backend/dist/services/gre-bgp-snapshot-history.js.map", + "backend/dist/services/gre-bgp-snapshot-history.d.ts", + "backend/dist/services/gre-bgp-snapshot-history.d.ts.map", + "backend/dist/services/gre-bgp-timing.js", + "backend/dist/services/gre-bgp-timing.js.map", + "backend/dist/services/gre-bgp-timing.d.ts", + "backend/dist/services/gre-bgp-timing.d.ts.map", + "backend/dist/services/gre-catalog-poll.js", + "backend/dist/services/gre-catalog-poll.js.map", + "backend/dist/services/gre-catalog-poll.d.ts", + "backend/dist/services/gre-catalog-poll.d.ts.map", + "backend/dist/services/gre-comment.js", + "backend/dist/services/gre-comment.js.map", + "backend/dist/services/gre-comment.d.ts", + "backend/dist/services/gre-comment.d.ts.map", + "backend/dist/services/gre-live.js", + "backend/dist/services/gre-live.js.map", + "backend/dist/services/gre-live.d.ts", + "backend/dist/services/gre-live.d.ts.map", + "backend/dist/services/gre-tunnels-live.js", + "backend/dist/services/gre-tunnels-live.js.map", + "backend/dist/services/gre-tunnels-live.d.ts", + "backend/dist/services/gre-tunnels-live.d.ts.map", + "backend/dist/services/internet-path-collector.js", + "backend/dist/services/internet-path-collector.js.map", + "backend/dist/services/internet-path-collector.d.ts", + "backend/dist/services/internet-path-collector.d.ts.map", + "backend/dist/services/internet-path-snapshot.js", + "backend/dist/services/internet-path-snapshot.js.map", + "backend/dist/services/internet-path-snapshot.d.ts", + "backend/dist/services/internet-path-snapshot.d.ts.map", + "backend/dist/services/ipsec-ca.js", + "backend/dist/services/ipsec-ca.js.map", + "backend/dist/services/ipsec-ca.d.ts", + "backend/dist/services/ipsec-ca.d.ts.map", + "backend/dist/services/ipsec-certificate-dn.js", + "backend/dist/services/ipsec-certificate-dn.js.map", + "backend/dist/services/ipsec-certificate-dn.d.ts", + "backend/dist/services/ipsec-certificate-dn.d.ts.map", + "backend/dist/services/ipsec-cleanup.js", + "backend/dist/services/ipsec-cleanup.js.map", + "backend/dist/services/ipsec-cleanup.d.ts", + "backend/dist/services/ipsec-cleanup.d.ts.map", + "backend/dist/services/ipsec-config.js", + "backend/dist/services/ipsec-config.js.map", + "backend/dist/services/ipsec-config.d.ts", + "backend/dist/services/ipsec-config.d.ts.map", + "backend/dist/services/ipsec-live.js", + "backend/dist/services/ipsec-live.js.map", + "backend/dist/services/ipsec-live.d.ts", + "backend/dist/services/ipsec-live.d.ts.map", + "backend/dist/services/ipsec-operation.js", + "backend/dist/services/ipsec-operation.js.map", + "backend/dist/services/ipsec-operation.d.ts", + "backend/dist/services/ipsec-operation.d.ts.map", + "backend/dist/services/ipsec-ros.js", + "backend/dist/services/ipsec-ros.js.map", + "backend/dist/services/ipsec-ros.d.ts", + "backend/dist/services/ipsec-ros.d.ts.map", + "backend/dist/services/mikrotik.js", + "backend/dist/services/mikrotik.js.map", + "backend/dist/services/mikrotik.d.ts", + "backend/dist/services/mikrotik.d.ts.map", + "backend/dist/services/monitoring-server-catalog.js", + "backend/dist/services/monitoring-server-catalog.js.map", + "backend/dist/services/monitoring-server-catalog.d.ts", + "backend/dist/services/monitoring-server-catalog.d.ts.map", + "backend/dist/services/ospf-catalog-poll.js", + "backend/dist/services/ospf-catalog-poll.js.map", + "backend/dist/services/ospf-catalog-poll.d.ts", + "backend/dist/services/ospf-catalog-poll.d.ts.map", + "backend/dist/services/ospf-effective-disabled.js", + "backend/dist/services/ospf-effective-disabled.js.map", + "backend/dist/services/ospf-effective-disabled.d.ts", + "backend/dist/services/ospf-effective-disabled.d.ts.map", + "backend/dist/services/ospf-observed-values.js", + "backend/dist/services/ospf-observed-values.js.map", + "backend/dist/services/ospf-observed-values.d.ts", + "backend/dist/services/ospf-observed-values.d.ts.map", + "backend/dist/services/ospf-optimizer-identity.js", + "backend/dist/services/ospf-optimizer-identity.js.map", + "backend/dist/services/ospf-optimizer-identity.d.ts", + "backend/dist/services/ospf-optimizer-identity.d.ts.map", + "backend/dist/services/ospf-optimizer-metrics.js", + "backend/dist/services/ospf-optimizer-metrics.js.map", + "backend/dist/services/ospf-optimizer-metrics.d.ts", + "backend/dist/services/ospf-optimizer-metrics.d.ts.map", + "backend/dist/services/ospf-optimizer-plan.js", + "backend/dist/services/ospf-optimizer-plan.js.map", + "backend/dist/services/ospf-optimizer-plan.d.ts", + "backend/dist/services/ospf-optimizer-plan.d.ts.map", + "backend/dist/services/ospf-route-parse.js", + "backend/dist/services/ospf-route-parse.js.map", + "backend/dist/services/ospf-route-parse.d.ts", + "backend/dist/services/ospf-route-parse.d.ts.map", + "backend/dist/services/ping-probe-replacement.js", + "backend/dist/services/ping-probe-replacement.js.map", + "backend/dist/services/ping-probe-replacement.d.ts", + "backend/dist/services/ping-probe-replacement.d.ts.map", + "backend/dist/services/poller.js", + "backend/dist/services/poller.js.map", + "backend/dist/services/poller.d.ts", + "backend/dist/services/poller.d.ts.map", + "backend/dist/services/recursive-apply.js", + "backend/dist/services/recursive-apply.js.map", + "backend/dist/services/recursive-apply.d.ts", + "backend/dist/services/recursive-apply.d.ts.map", + "backend/dist/services/recursive-route-catalog.js", + "backend/dist/services/recursive-route-catalog.js.map", + "backend/dist/services/recursive-route-catalog.d.ts", + "backend/dist/services/recursive-route-catalog.d.ts.map", + "backend/dist/services/recursive-route-validation.js", + "backend/dist/services/recursive-route-validation.js.map", + "backend/dist/services/recursive-route-validation.d.ts", + "backend/dist/services/recursive-route-validation.d.ts.map", + "backend/dist/services/ros-metric-parse.js", + "backend/dist/services/ros-metric-parse.js.map", + "backend/dist/services/ros-metric-parse.d.ts", + "backend/dist/services/ros-metric-parse.d.ts.map", + "backend/dist/services/ros-ops.js", + "backend/dist/services/ros-ops.js.map", + "backend/dist/services/ros-ops.d.ts", + "backend/dist/services/ros-ops.d.ts.map", + "backend/dist/services/s3-backup-client.js", + "backend/dist/services/s3-backup-client.js.map", + "backend/dist/services/s3-backup-client.d.ts", + "backend/dist/services/s3-backup-client.d.ts.map", + "backend/dist/services/scheduler-recorded-run.js", + "backend/dist/services/scheduler-recorded-run.js.map", + "backend/dist/services/scheduler-recorded-run.d.ts", + "backend/dist/services/scheduler-recorded-run.d.ts.map", + "backend/dist/services/scheduler-running.js", + "backend/dist/services/scheduler-running.js.map", + "backend/dist/services/scheduler-running.d.ts", + "backend/dist/services/scheduler-running.d.ts.map", + "backend/dist/services/scheduler.js", + "backend/dist/services/scheduler.js.map", + "backend/dist/services/scheduler.d.ts", + "backend/dist/services/scheduler.d.ts.map", + "backend/dist/services/serial-operation.js", + "backend/dist/services/serial-operation.js.map", + "backend/dist/services/serial-operation.d.ts", + "backend/dist/services/serial-operation.d.ts.map", + "backend/dist/services/servers-rest-ping-collector.js", + "backend/dist/services/servers-rest-ping-collector.js.map", + "backend/dist/services/servers-rest-ping-collector.d.ts", + "backend/dist/services/servers-rest-ping-collector.d.ts.map", + "backend/dist/services/speed-history-dto.js", + "backend/dist/services/speed-history-dto.js.map", + "backend/dist/services/speed-history-dto.d.ts", + "backend/dist/services/speed-history-dto.d.ts.map", + "backend/dist/services/speed-probe-dto.js", + "backend/dist/services/speed-probe-dto.js.map", + "backend/dist/services/speed-probe-dto.d.ts", + "backend/dist/services/speed-probe-dto.d.ts.map", + "backend/dist/services/speed-probe-replacement.js", + "backend/dist/services/speed-probe-replacement.js.map", + "backend/dist/services/speed-probe-replacement.d.ts", + "backend/dist/services/speed-probe-replacement.d.ts.map", + "backend/dist/services/speed-run-error-message.js", + "backend/dist/services/speed-run-error-message.js.map", + "backend/dist/services/speed-run-error-message.d.ts", + "backend/dist/services/speed-run-error-message.d.ts.map", + "backend/dist/services/speed-run-persistence.js", + "backend/dist/services/speed-run-persistence.js.map", + "backend/dist/services/speed-run-persistence.d.ts", + "backend/dist/services/speed-run-persistence.d.ts.map", + "backend/dist/services/statistics-aggregate.js", + "backend/dist/services/statistics-aggregate.js.map", + "backend/dist/services/statistics-aggregate.d.ts", + "backend/dist/services/statistics-aggregate.d.ts.map", + "backend/dist/services/system-database-backup.js", + "backend/dist/services/system-database-backup.js.map", + "backend/dist/services/system-database-backup.d.ts", + "backend/dist/services/system-database-backup.d.ts.map", + "backend/dist/services/system-database-cache.js", + "backend/dist/services/system-database-cache.js.map", + "backend/dist/services/system-database-cache.d.ts", + "backend/dist/services/system-database-cache.d.ts.map", + "backend/dist/services/system-database-operation.js", + "backend/dist/services/system-database-operation.js.map", + "backend/dist/services/system-database-operation.d.ts", + "backend/dist/services/system-database-operation.d.ts.map", + "backend/dist/services/system-database-partition-restore.js", + "backend/dist/services/system-database-partition-restore.js.map", + "backend/dist/services/system-database-partition-restore.d.ts", + "backend/dist/services/system-database-partition-restore.d.ts.map", + "backend/dist/services/system-database-restore-execution.js", + "backend/dist/services/system-database-restore-execution.js.map", + "backend/dist/services/system-database-restore-execution.d.ts", + "backend/dist/services/system-database-restore-execution.d.ts.map", + "backend/dist/services/traffic-collector.js", + "backend/dist/services/traffic-collector.js.map", + "backend/dist/services/traffic-collector.d.ts", + "backend/dist/services/traffic-collector.d.ts.map", + "backend/dist/services/traffic-flow-analytics.js", + "backend/dist/services/traffic-flow-analytics.js.map", + "backend/dist/services/traffic-flow-analytics.d.ts", + "backend/dist/services/traffic-flow-analytics.d.ts.map", + "backend/dist/services/traffic-flow-apps.js", + "backend/dist/services/traffic-flow-apps.js.map", + "backend/dist/services/traffic-flow-apps.d.ts", + "backend/dist/services/traffic-flow-apps.d.ts.map", + "backend/dist/services/traffic-flow-brands.js", + "backend/dist/services/traffic-flow-brands.js.map", + "backend/dist/services/traffic-flow-brands.d.ts", + "backend/dist/services/traffic-flow-brands.d.ts.map", + "backend/dist/services/traffic-flow-classify.js", + "backend/dist/services/traffic-flow-classify.js.map", + "backend/dist/services/traffic-flow-classify.d.ts", + "backend/dist/services/traffic-flow-classify.d.ts.map", + "backend/dist/services/traffic-flow-collector-ipc.js", + "backend/dist/services/traffic-flow-collector-ipc.js.map", + "backend/dist/services/traffic-flow-collector-ipc.d.ts", + "backend/dist/services/traffic-flow-collector-ipc.d.ts.map", + "backend/dist/services/traffic-flow-collector-worker.js", + "backend/dist/services/traffic-flow-collector-worker.js.map", + "backend/dist/services/traffic-flow-collector-worker.d.ts", + "backend/dist/services/traffic-flow-collector-worker.d.ts.map", + "backend/dist/services/traffic-flow-dedup.js", + "backend/dist/services/traffic-flow-dedup.js.map", + "backend/dist/services/traffic-flow-dedup.d.ts", + "backend/dist/services/traffic-flow-dedup.d.ts.map", + "backend/dist/services/traffic-flow-dest.js", + "backend/dist/services/traffic-flow-dest.js.map", + "backend/dist/services/traffic-flow-dest.d.ts", + "backend/dist/services/traffic-flow-dest.d.ts.map", + "backend/dist/services/traffic-flow-engine.js", + "backend/dist/services/traffic-flow-engine.js.map", + "backend/dist/services/traffic-flow-engine.d.ts", + "backend/dist/services/traffic-flow-engine.d.ts.map", + "backend/dist/services/traffic-flow-facts-filter.js", + "backend/dist/services/traffic-flow-facts-filter.js.map", + "backend/dist/services/traffic-flow-facts-filter.d.ts", + "backend/dist/services/traffic-flow-facts-filter.d.ts.map", + "backend/dist/services/traffic-flow-facts-rebuild.js", + "backend/dist/services/traffic-flow-facts-rebuild.js.map", + "backend/dist/services/traffic-flow-facts-rebuild.d.ts", + "backend/dist/services/traffic-flow-facts-rebuild.d.ts.map", + "backend/dist/services/traffic-flow-facts.js", + "backend/dist/services/traffic-flow-facts.js.map", + "backend/dist/services/traffic-flow-facts.d.ts", + "backend/dist/services/traffic-flow-facts.d.ts.map", + "backend/dist/services/traffic-flow-geoip.js", + "backend/dist/services/traffic-flow-geoip.js.map", + "backend/dist/services/traffic-flow-geoip.d.ts", + "backend/dist/services/traffic-flow-geoip.d.ts.map", + "backend/dist/services/traffic-flow-host-file-builders.js", + "backend/dist/services/traffic-flow-host-file-builders.js.map", + "backend/dist/services/traffic-flow-host-file-builders.d.ts", + "backend/dist/services/traffic-flow-host-file-builders.d.ts.map", + "backend/dist/services/traffic-flow-host-files.js", + "backend/dist/services/traffic-flow-host-files.js.map", + "backend/dist/services/traffic-flow-host-files.d.ts", + "backend/dist/services/traffic-flow-host-files.d.ts.map", + "backend/dist/services/traffic-flow-ifaces.js", + "backend/dist/services/traffic-flow-ifaces.js.map", + "backend/dist/services/traffic-flow-ifaces.d.ts", + "backend/dist/services/traffic-flow-ifaces.d.ts.map", + "backend/dist/services/traffic-flow-ifindex.js", + "backend/dist/services/traffic-flow-ifindex.js.map", + "backend/dist/services/traffic-flow-ifindex.d.ts", + "backend/dist/services/traffic-flow-ifindex.d.ts.map", + "backend/dist/services/traffic-flow-ingest.js", + "backend/dist/services/traffic-flow-ingest.js.map", + "backend/dist/services/traffic-flow-ingest.d.ts", + "backend/dist/services/traffic-flow-ingest.d.ts.map", + "backend/dist/services/traffic-flow-ip.js", + "backend/dist/services/traffic-flow-ip.js.map", + "backend/dist/services/traffic-flow-ip.d.ts", + "backend/dist/services/traffic-flow-ip.d.ts.map", + "backend/dist/services/traffic-flow-key-execution.js", + "backend/dist/services/traffic-flow-key-execution.js.map", + "backend/dist/services/traffic-flow-key-execution.d.ts", + "backend/dist/services/traffic-flow-key-execution.d.ts.map", + "backend/dist/services/traffic-flow-map-exporter.js", + "backend/dist/services/traffic-flow-map-exporter.js.map", + "backend/dist/services/traffic-flow-map-exporter.d.ts", + "backend/dist/services/traffic-flow-map-exporter.d.ts.map", + "backend/dist/services/traffic-flow-map-hops.js", + "backend/dist/services/traffic-flow-map-hops.js.map", + "backend/dist/services/traffic-flow-map-hops.d.ts", + "backend/dist/services/traffic-flow-map-hops.d.ts.map", + "backend/dist/services/traffic-flow-overlay.js", + "backend/dist/services/traffic-flow-overlay.js.map", + "backend/dist/services/traffic-flow-overlay.d.ts", + "backend/dist/services/traffic-flow-overlay.d.ts.map", + "backend/dist/services/traffic-flow-parse.js", + "backend/dist/services/traffic-flow-parse.js.map", + "backend/dist/services/traffic-flow-parse.d.ts", + "backend/dist/services/traffic-flow-parse.d.ts.map", + "backend/dist/services/traffic-flow-planes.js", + "backend/dist/services/traffic-flow-planes.js.map", + "backend/dist/services/traffic-flow-planes.d.ts", + "backend/dist/services/traffic-flow-planes.d.ts.map", + "backend/dist/services/traffic-flow-purge-execution.js", + "backend/dist/services/traffic-flow-purge-execution.js.map", + "backend/dist/services/traffic-flow-purge-execution.d.ts", + "backend/dist/services/traffic-flow-purge-execution.d.ts.map", + "backend/dist/services/traffic-flow-ripe.js", + "backend/dist/services/traffic-flow-ripe.js.map", + "backend/dist/services/traffic-flow-ripe.d.ts", + "backend/dist/services/traffic-flow-ripe.d.ts.map", + "backend/dist/services/traffic-flow-settings.js", + "backend/dist/services/traffic-flow-settings.js.map", + "backend/dist/services/traffic-flow-settings.d.ts", + "backend/dist/services/traffic-flow-settings.d.ts.map", + "backend/dist/services/traffic-flow-topology.js", + "backend/dist/services/traffic-flow-topology.js.map", + "backend/dist/services/traffic-flow-topology.d.ts", + "backend/dist/services/traffic-flow-topology.d.ts.map", + "backend/dist/services/traffic-flow-worker-shutdown.js", + "backend/dist/services/traffic-flow-worker-shutdown.js.map", + "backend/dist/services/traffic-flow-worker-shutdown.d.ts", + "backend/dist/services/traffic-flow-worker-shutdown.d.ts.map", + "backend/dist/services/traffic-observation.js", + "backend/dist/services/traffic-observation.js.map", + "backend/dist/services/traffic-observation.d.ts", + "backend/dist/services/traffic-observation.d.ts.map", + "backend/dist/services/traffic-rate.js", + "backend/dist/services/traffic-rate.js.map", + "backend/dist/services/traffic-rate.d.ts", + "backend/dist/services/traffic-rate.d.ts.map", + "backend/dist/services/traffic-users.js", + "backend/dist/services/traffic-users.js.map", + "backend/dist/services/traffic-users.d.ts", + "backend/dist/services/traffic-users.d.ts.map", + "backend/dist/services/uptime-collector.js", + "backend/dist/services/uptime-collector.js.map", + "backend/dist/services/uptime-collector.d.ts", + "backend/dist/services/uptime-collector.d.ts.map", + "backend/dist/services/uptime-probe-observations.js", + "backend/dist/services/uptime-probe-observations.js.map", + "backend/dist/services/uptime-probe-observations.d.ts", + "backend/dist/services/uptime-probe-observations.d.ts.map", + "backend/dist/services/uptime-speed-service.js", + "backend/dist/services/uptime-speed-service.js.map", + "backend/dist/services/uptime-speed-service.d.ts", + "backend/dist/services/uptime-speed-service.d.ts.map", + "backend/dist/services/vxlan-live.js", + "backend/dist/services/vxlan-live.js.map", + "backend/dist/services/vxlan-live.d.ts", + "backend/dist/services/vxlan-live.d.ts.map", + "backend/dist/services/wg-keys.js", + "backend/dist/services/wg-keys.js.map", + "backend/dist/services/wg-keys.d.ts", + "backend/dist/services/wg-keys.d.ts.map", + "backend/dist/services/wireguard-config.js", + "backend/dist/services/wireguard-config.js.map", + "backend/dist/services/wireguard-config.d.ts", + "backend/dist/services/wireguard-config.d.ts.map", + "backend/dist/services/wireguard-delete.js", + "backend/dist/services/wireguard-delete.js.map", + "backend/dist/services/wireguard-delete.d.ts", + "backend/dist/services/wireguard-delete.d.ts.map", + "backend/dist/services/wireguard-import-apply.js", + "backend/dist/services/wireguard-import-apply.js.map", + "backend/dist/services/wireguard-import-apply.d.ts", + "backend/dist/services/wireguard-import-apply.d.ts.map", + "backend/dist/services/wireguard-import-revision.js", + "backend/dist/services/wireguard-import-revision.js.map", + "backend/dist/services/wireguard-import-revision.d.ts", + "backend/dist/services/wireguard-import-revision.d.ts.map", + "backend/dist/services/wireguard-import.js", + "backend/dist/services/wireguard-import.js.map", + "backend/dist/services/wireguard-import.d.ts", + "backend/dist/services/wireguard-import.d.ts.map", + "backend/dist/services/wireguard-live.js", + "backend/dist/services/wireguard-live.js.map", + "backend/dist/services/wireguard-live.d.ts", + "backend/dist/services/wireguard-live.d.ts.map", + "backend/dist/services/wireguard-observed-values.js", + "backend/dist/services/wireguard-observed-values.js.map", + "backend/dist/services/wireguard-observed-values.d.ts", + "backend/dist/services/wireguard-observed-values.d.ts.map", + "backend/dist/services/wireguard-ros.js", + "backend/dist/services/wireguard-ros.js.map", + "backend/dist/services/wireguard-ros.d.ts", + "backend/dist/services/wireguard-ros.d.ts.map", + "backend/dist/test/pg.js", + "backend/dist/test/pg.js.map", + "backend/dist/test/pg.d.ts", + "backend/dist/test/pg.d.ts.map", + "backend/dist/types/scheduler-run-snapshot.js", + "backend/dist/types/scheduler-run-snapshot.js.map", + "backend/dist/types/scheduler-run-snapshot.d.ts", + "backend/dist/types/scheduler-run-snapshot.d.ts.map", + "backend/dist/types/server.js", + "backend/dist/types/server.js.map", + "backend/dist/types/server.d.ts", + "backend/dist/types/server.d.ts.map", + "backend/dist/utils/abort-signals.js", + "backend/dist/utils/abort-signals.js.map", + "backend/dist/utils/abort-signals.d.ts", + "backend/dist/utils/abort-signals.d.ts.map", + "backend/dist/utils/ros-src-address.js", + "backend/dist/utils/ros-src-address.js.map", + "backend/dist/utils/ros-src-address.d.ts", + "backend/dist/utils/ros-src-address.d.ts.map", + "backend/dist/utils/server-id.js", + "backend/dist/utils/server-id.js.map", + "backend/dist/utils/server-id.d.ts", + "backend/dist/utils/server-id.d.ts.map", + "packages/contracts/dist/alerts.js", + "packages/contracts/dist/alerts.js.map", + "packages/contracts/dist/alerts.d.ts", + "packages/contracts/dist/alerts.d.ts.map", + "packages/contracts/dist/backups.js", + "packages/contracts/dist/backups.js.map", + "packages/contracts/dist/backups.d.ts", + "packages/contracts/dist/backups.d.ts.map", + "packages/contracts/dist/certificates.js", + "packages/contracts/dist/certificates.js.map", + "packages/contracts/dist/certificates.d.ts", + "packages/contracts/dist/certificates.d.ts.map", + "packages/contracts/dist/containers.js", + "packages/contracts/dist/containers.js.map", + "packages/contracts/dist/containers.d.ts", + "packages/contracts/dist/containers.d.ts.map", + "packages/contracts/dist/events.js", + "packages/contracts/dist/events.js.map", + "packages/contracts/dist/events.d.ts", + "packages/contracts/dist/events.d.ts.map", + "packages/contracts/dist/evobgp.js", + "packages/contracts/dist/evobgp.js.map", + "packages/contracts/dist/evobgp.d.ts", + "packages/contracts/dist/evobgp.d.ts.map", + "packages/contracts/dist/geoip.js", + "packages/contracts/dist/geoip.js.map", + "packages/contracts/dist/geoip.d.ts", + "packages/contracts/dist/geoip.d.ts.map", + "packages/contracts/dist/index.js", + "packages/contracts/dist/index.js.map", + "packages/contracts/dist/index.d.ts", + "packages/contracts/dist/index.d.ts.map", + "packages/contracts/dist/ipsec.js", + "packages/contracts/dist/ipsec.js.map", + "packages/contracts/dist/ipsec.d.ts", + "packages/contracts/dist/ipsec.d.ts.map", + "packages/contracts/dist/ospf-optimizer.js", + "packages/contracts/dist/ospf-optimizer.js.map", + "packages/contracts/dist/ospf-optimizer.d.ts", + "packages/contracts/dist/ospf-optimizer.d.ts.map", + "packages/contracts/dist/servers.js", + "packages/contracts/dist/servers.js.map", + "packages/contracts/dist/servers.d.ts", + "packages/contracts/dist/servers.d.ts.map", + "packages/contracts/dist/statistics.js", + "packages/contracts/dist/statistics.js.map", + "packages/contracts/dist/statistics.d.ts", + "packages/contracts/dist/statistics.d.ts.map", + "packages/contracts/dist/traffic-flow.js", + "packages/contracts/dist/traffic-flow.js.map", + "packages/contracts/dist/traffic-flow.d.ts", + "packages/contracts/dist/traffic-flow.d.ts.map", + "packages/contracts/dist/uptime-probes.js", + "packages/contracts/dist/uptime-probes.js.map", + "packages/contracts/dist/uptime-probes.d.ts", + "packages/contracts/dist/uptime-probes.d.ts.map", + "packages/contracts/dist/uptime-speed.js", + "packages/contracts/dist/uptime-speed.js.map", + "packages/contracts/dist/uptime-speed.d.ts", + "packages/contracts/dist/uptime-speed.d.ts.map", + "packages/contracts/dist/users.js", + "packages/contracts/dist/users.js.map", + "packages/contracts/dist/users.d.ts", + "packages/contracts/dist/users.d.ts.map", + "packages/contracts/dist/wireguard.js", + "packages/contracts/dist/wireguard.js.map", + "packages/contracts/dist/wireguard.d.ts", + "packages/contracts/dist/wireguard.d.ts.map" + ], + "missing": [], + "different": [] +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/traffic-history-deployed-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-deployed-2026-10-12.json new file mode 100644 index 00000000..a679c081 --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-deployed-2026-10-12.json @@ -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" + ] +} diff --git a/docs/ux-ui/acceptance/traffic-history-interop-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-interop-2026-10-12.json new file mode 100644 index 00000000..f49322c2 --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-interop-2026-10-12.json @@ -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" +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/traffic-history-native-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-native-2026-10-12.json new file mode 100644 index 00000000..ae4519a4 --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-native-2026-10-12.json @@ -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 +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/traffic-history-runtime-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-runtime-2026-10-12.json new file mode 100644 index 00000000..2f91f3e8 --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-runtime-2026-10-12.json @@ -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 + } + } +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/traffic-history-source-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-source-2026-10-12.json new file mode 100644 index 00000000..9e5e2bde --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-source-2026-10-12.json @@ -0,0 +1,10 @@ +{ + "date": "2026-10-12", + "block": "12.5.178", + "matched": { + "backend": 448, + "frontend": 521 + }, + "different": [], + "missing": [] +} \ No newline at end of file diff --git a/docs/ux-ui/acceptance/traffic-history-sql-2026-10-12.json b/docs/ux-ui/acceptance/traffic-history-sql-2026-10-12.json new file mode 100644 index 00000000..c2256901 --- /dev/null +++ b/docs/ux-ui/acceptance/traffic-history-sql-2026-10-12.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/docs/ux-ui/screens/traffic-history-error-1440-2026-10-12.png b/docs/ux-ui/screens/traffic-history-error-1440-2026-10-12.png new file mode 100644 index 00000000..df86619c Binary files /dev/null and b/docs/ux-ui/screens/traffic-history-error-1440-2026-10-12.png differ diff --git a/docs/ux-ui/screens/traffic-history-error-320-2026-10-12.png b/docs/ux-ui/screens/traffic-history-error-320-2026-10-12.png new file mode 100644 index 00000000..0c4e28e7 Binary files /dev/null and b/docs/ux-ui/screens/traffic-history-error-320-2026-10-12.png differ diff --git a/lib/fmt-rate.ts b/lib/fmt-rate.ts index a0b41e76..db121dd6 100644 --- a/lib/fmt-rate.ts +++ b/lib/fmt-rate.ts @@ -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)} ГБ` } diff --git a/lib/traffic-observations.test.ts b/lib/traffic-observations.test.ts new file mode 100644 index 00000000..49d02fee --- /dev/null +++ b/lib/traffic-observations.test.ts @@ -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") diff --git a/lib/traffic-observations.ts b/lib/traffic-observations.ts new file mode 100644 index 00000000..84153a6f --- /dev/null +++ b/lib/traffic-observations.ts @@ -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 +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 } +} diff --git a/tests/e2e/traffic-catalog.spec.ts b/tests/e2e/traffic-catalog.spec.ts index 6155cae3..ea18753f 100644 --- a/tests/e2e/traffic-catalog.spec.ts +++ b/tests/e2e/traffic-catalog.spec.ts @@ -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(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') diff --git a/tests/e2e/traffic-details.spec.ts b/tests/e2e/traffic-details.spec.ts index 092f756b..e6c7b493 100644 --- a/tests/e2e/traffic-details.spec.ts +++ b/tests/e2e/traffic-details.spec.ts @@ -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 | undefined, txSeries: undefined as number[] | undefined, release: () => {} } const gate = new Promise(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) diff --git a/tests/e2e/traffic-history-confidence.spec.ts b/tests/e2e/traffic-history-confidence.spec.ts new file mode 100644 index 00000000..8c969b35 --- /dev/null +++ b/tests/e2e/traffic-history-confidence.spec.ts @@ -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") +}) diff --git a/tests/e2e/traffic-ipv6-labels.spec.ts b/tests/e2e/traffic-ipv6-labels.spec.ts index 63cd438c..9454d95c 100644 --- a/tests/e2e/traffic-ipv6-labels.spec.ts +++ b/tests/e2e/traffic-ipv6-labels.spec.ts @@ -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() diff --git a/tests/e2e/traffic-list.spec.ts b/tests/e2e/traffic-list.spec.ts index da68a784..f48534e7 100644 --- a/tests/e2e/traffic-list.spec.ts +++ b/tests/e2e/traffic-list.spec.ts @@ -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(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() diff --git a/tests/e2e/traffic-live.spec.ts b/tests/e2e/traffic-live.spec.ts index 7ebdc4a7..aca0f755 100644 --- a/tests/e2e/traffic-live.spec.ts +++ b/tests/e2e/traffic-live.spec.ts @@ -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') diff --git a/tests/e2e/traffic-mobile.spec.ts b/tests/e2e/traffic-mobile.spec.ts index 0fae27e6..01a2b83a 100644 --- a/tests/e2e/traffic-mobile.spec.ts +++ b/tests/e2e/traffic-mobile.spec.ts @@ -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') } diff --git a/tests/e2e/traffic-observation-fixture.ts b/tests/e2e/traffic-observation-fixture.ts new file mode 100644 index 00000000..24186a73 --- /dev/null +++ b/tests/e2e/traffic-observation-fixture.ts @@ -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 +} diff --git a/tests/e2e/traffic-response-validation.spec.ts b/tests/e2e/traffic-response-validation.spec.ts index 484422f5..1ab76349 100644 --- a/tests/e2e/traffic-response-validation.spec.ts +++ b/tests/e2e/traffic-response-validation.spec.ts @@ -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()