fix(alerts): учитывать свежесть и настройки источников оповещений

This commit is contained in:
Denozordec committed 2026-10-12 15:12:15 +07:00
1 parent 8ffb8b5f00
commit db7105119d
19 files changed
+1614 -233

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
"test:alerts-telegram": "tsx src/services/alerts-outbox-retry.test.ts && tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
"test:alert-engine": "tsx src/services/alert-engine/server-signal-identity.test.ts && tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/gre-bgp-snapshot-collector.test.ts && tsx src/services/gre-bgp-snapshot-history.test.ts && tsx src/services/gre-bgp-snapshot-sql.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
"test:alert-engine": "tsx src/services/alert-engine/server-signal-identity.test.ts && tsx src/services/alert-engine/collector-source-signals.test.ts && tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/gre-bgp-snapshot-collector.test.ts && tsx src/services/gre-bgp-snapshot-history.test.ts && tsx src/services/gre-bgp-snapshot-sql.test.ts && tsx src/services/alert-engine/collector-source-sql.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -0,0 +1,91 @@
import assert from "node:assert/strict"
import { collectSourceSignals, probePeriodMs, type CollectorSourceConfig, type CollectorSourceRuns, type CollectorRunRow } from "./collector-source-signals.js"
import { evaluateRule } from "./evaluate-rule.js"
import type { ApiAlertRule } from "../alerts-service.js"
const now = Date.parse("2026-10-12T10:00:00Z"), iso = (delta = 0) => new Date(now + delta).toISOString()
const config: CollectorSourceConfig = {
servers: [{ id: 1, name: "one", host: "host1", enabled: true, updatedAt: iso(-900_000) }, { id: 2, name: "two", host: "host2", enabled: true }],
probes: [{ id: "slow", name: "slow", target: "target", srcServerId: 1, srcInterface: "ether1", enabled: true, intervalSec: 120 },
{ id: "fast", name: "fast", target: "target", srcServerId: 2, srcInterface: "ether2", enabled: true, intervalSec: 0 }],
uptime: { enabled: true, resourcesEnabled: true, pingEnabled: true, intervalSec: 15, probeIntervalSec: 15 },
rest: { enabled: true, intervalSec: 120 }, traffic: { enabled: true, intervalSec: 30 },
}
const device = (id = 1) => ({ serverId: id, name: id === 1 ? "one" : "two", host: `host${id}` })
const resource = (status = "offline", id = 1) => ({ ...device(id), status })
const rest = (ok = false) => ({ ...device(), ok })
const traffic = (rx = 0, tx = 0) => ({ ...device(), ok: true, sumRxMbps: rx, sumTxMbps: tx })
const probe = (id = "slow") => { const p = config.probes.find(p => p.id === id)!; return { probeId: id, name: p.name, target: p.target, srcServerId: p.srcServerId, srcServerName: p.srcServerId === 1 ? "one" : "two", srcInterface: p.srcInterface, ok: true, status: "up", rttMs: 4, lossPct: 0 } }
const row = (job: string, entries: unknown[], delta = 0, extra = {}): CollectorRunRow => ({ finishedAt: iso(delta), resultJson: { v: 1, job, sampledAt: iso(delta), [job === "uptime_ping" ? "probes" : "servers"]: entries, ...extra } })
const empty: CollectorSourceRuns = { resources: [], rest: [], ping: [], traffic: [] }
const run = (over: Partial<CollectorSourceRuns>, cfg = config) => collectSourceSignals(cfg, { ...empty, ...over }, now)
const result = run({ resources: [row("uptime_resources", [resource()])], traffic: [row("traffic", [traffic()])], ping: [row("uptime_ping", [probe()])] })
assert.equal(result.servers[0].status, "offline"); assert.equal(result.trafficByServer[0].rxMbps, 0); assert.equal(result.probes[0].rttMs, 4)
// Stale/future completion AND sample boundaries, errors, empty current and malformed values must not resurrect old success.
for (const [field, job, value, period] of [["resources", "uptime_resources", resource(), 15_000], ["rest", "servers_rest_ping", rest(), 120_000], ["traffic", "traffic", traffic(), 30_000]] as const) {
const old = row(job, [value], -1_000)
const outputs = (rows: CollectorRunRow[]) => { const r = run({ [field]: rows }); return field === "traffic" ? r.trafficByServer : r.servers }
for (const current of [
{ finishedAt: iso(), resultJson: null }, { finishedAt: iso(), resultJson: "{" },
row(job, []), row(job, [value], 0, { skipped: true }), row(job, [value], 0, { fatalError: "failure" }),
row(job, [value], -3 * period - 1), row(job, [value], period + 1),
{ ...row(job, [value]), finishedAt: iso(-3 * period - 1) }, { ...row(job, [value]), finishedAt: iso(period + 1) },
row(job, [value], 0, { sampledAt: "invalid" }), row(job, [value], 0, { sampledAt: 1 }),
row(job, [value], 0, { v: 99 }), row(job, [value], 0, { servers: {} }),
]) assert.deepEqual(outputs([current, old]), [])
assert.equal(outputs([row(job, [value], -3 * period)]).length, 1)
assert.equal(outputs([row(job, [value], period)]).length, 1)
}
assert.deepEqual(run({ resources: [row("uptime_resources", [resource("unknown")]), row("uptime_resources", [resource()])] }).servers, [])
assert.deepEqual(run({ resources: [row("uptime_resources", [{ ...resource(), status: ["offline"] }])] }).servers, [])
for (const value of [{ ...traffic(), ok: false }, { ...device(), ok: true }, { ...traffic(), sumRxMbps: null }, { ...traffic(), sumRxMbps: -1 }, { ...traffic(), sumTxMbps: "0" }]) {
assert.deepEqual(run({ traffic: [row("traffic", [value]), row("traffic", [traffic(100, 100)], -1_000)] }).trafficByServer, [])
}
// Mixed outcomes keep only independently confirmed current devices.
assert.deepEqual(run({ resources: [row("uptime_resources", [resource("unknown"), resource("online", 2)])] }).servers.map(s => s.name), ["two"])
assert.deepEqual(run({ resources: [row("uptime_resources", [resource(), resource()])] }).servers, [])
assert.deepEqual(run({ resources: [row("uptime_resources", [resource()])] }, { ...config, servers: [] }).servers, [])
assert.deepEqual(run({ resources: [row("uptime_resources", [resource()])] }, { ...config, servers: config.servers.map(s => ({ ...s, enabled: false })) }).servers, [])
// Scheduler semantics: dedicated switches override the legacy master flag; absent settings never seed defaults.
assert.deepEqual(run({ resources: [row("uptime_resources", [resource()])], rest: [row("servers_rest_ping", [rest()])], traffic: [row("traffic", [traffic()])], ping: [row("uptime_ping", [probe()])] }, {
...config, uptime: { ...config.uptime!, resourcesEnabled: false, pingEnabled: false }, rest: { ...config.rest!, enabled: false }, traffic: { ...config.traffic!, enabled: false },
}), { servers: [], probes: [], trafficByServer: [] })
assert.equal(run({ resources: [row("uptime_resources", [resource()])] }, { ...config, uptime: { ...config.uptime!, enabled: false } }).servers.length, 1)
assert.deepEqual(run({ resources: [row("uptime_resources", [resource()])] }, { ...config, uptime: undefined }).servers, [])
assert.equal(run({ resources: [row("uptime_resources", [resource()], -200_000)] }, { ...config, uptime: { ...config.uptime!, intervalSec: 300 } }).servers.length, 1)
// Disabled or stale REST cannot synthesize recovery from a different resource timeline.
assert.equal(run({ resources: [row("uptime_resources", [resource()])], rest: [row("servers_rest_ping", [rest(true)], -361_000)] }).servers[0].status, "offline")
const merged = run({ resources: [row("uptime_resources", [resource()])], rest: [row("servers_rest_ping", [rest(true)])] }).servers[0]
assert.equal(merged.status, "online"); assert.equal(merged.prevStatus, null); assert.equal(merged.prev2Status, null)
const recovered = run({ resources: [row("uptime_resources", [resource("online")]), row("uptime_resources", [resource()], -15_000)] }).servers[0]
assert.equal(recovered.prevStatus, "offline")
assert.equal(run({ resources: [row("uptime_resources", [resource("online")]), row("uptime_resources", [resource("unknown")], -1_000), row("uptime_resources", [resource()], -15_000)] }).servers[0].prevStatus, null)
// Slow probes stay available through many fast-only/empty due batches, but not through a global failed run.
const due = [row("uptime_ping", [probe("fast")]), row("uptime_ping", [], -15_000), row("uptime_ping", [probe()], -90_000)]
assert.deepEqual(run({ ping: due }).probes.map(p => p.key).sort(), ["fast → target", "slow → target"])
assert.deepEqual(run({ ping: [{ finishedAt: iso(), resultJson: null }, ...due] }).probes, [])
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], -360_001)] }).probes, [])
assert.equal(run({ ping: [row("uptime_ping", [probe()], -360_000)] }).probes.length, 1)
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], 15_001)] }).probes, [])
for (const changed of [{ ...probe(), target: "old-target" }, { ...probe(), srcInterface: "old-interface" }, { ...probe(), srcServerId: 2 }, { ...probe(), status: "unknown" }, { ...probe(), rttMs: -1 }, { ...probe(), lossPct: 101 }]) {
assert.deepEqual(run({ ping: [row("uptime_ping", [changed]), row("uptime_ping", [probe()], -15_000)] }).probes, [])
}
assert.deepEqual(run({ ping: [row("uptime_ping", [probe(), probe()])] }).probes, [])
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()])] }, { ...config, probes: [{ ...config.probes[0], enabled: false }] }).probes, [])
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()])] }, { ...config, probes: [config.probes[0], { ...config.probes[0], id: "duplicate" }] }).probes, [])
assert.deepEqual(run({ ping: [row("uptime_ping", [probe()], -1_000)] }, { ...config, servers: [{ ...config.servers[0], updatedAt: iso() }] }).probes, [])
assert.equal(run({ ping: [row("uptime_ping", [{ ...probe(), ok: false, status: "down", rttMs: null, lossPct: 100 }])] }).probes[0].status, "down")
assert.equal(probePeriodMs(config, { ...config.probes[0], intervalSec: 1 }), 15_000)
// Suppressed measurements do not fire real evaluation paths (offline, loss, low traffic).
const missing = { sampledAt: iso(), ...run({ resources: [row("uptime_resources", [resource()], -46_000)], traffic: [row("traffic", [{ ...device(), ok: false }])], ping: [{ finishedAt: iso(), resultJson: null }] }), greTunnels: [], bgpSessions: [] }
const positive = { sampledAt: iso(), ...result, probes: [{ ...result.probes[0], status: "down" as const, rttMs: null, lossPct: 100 }], greTunnels: [], bgpSessions: [] }
for (const [type, target, condition] of [["server", "one", "offline"], ["loss", "slow → target", "> 5%"], ["traffic", "one", "RX < 1"]] as const) {
const rule = { id: type, name: type, type, targets: [target], conditions: [condition], enabled: true } as unknown as ApiAlertRule
assert.equal(evaluateRule(rule, missing), null)
assert.ok(evaluateRule(rule, positive))
}
console.log("collector source guards: settings/identity/current/unknown/time boundaries/due-only probes/history/evaluation passed")
@@ -0,0 +1,129 @@
import { createServerSignalIdentityGuard, type AlertServerIdentity } from "./server-signal-identity.js"
import type { ProbeSignal, ServerSignal, TrafficServerSignal } from "./types.js"
export interface CollectorRunRow { resultJson: unknown; finishedAt: Date | string }
export interface ProbeIdentity {
id: string; name: string; target: string; srcServerId: number; srcInterface: string
enabled: boolean; intervalSec: number; updatedAt?: Date | string
}
export interface CollectorSourceConfig {
servers: Array<AlertServerIdentity & { updatedAt?: Date | string }>
probes: ProbeIdentity[]
uptime?: { enabled: boolean; resourcesEnabled: boolean | null; pingEnabled: boolean | null; intervalSec: number; probeIntervalSec: number }
rest?: { enabled: boolean; intervalSec: number }
traffic?: { enabled: boolean; intervalSec: number }
}
type JsonRow = Record<string, unknown>
interface Run { sampledAt: string; sampledMs: number; finishedMs: number; rows: JsonRow[] }
export interface CollectorSourceRuns { resources: CollectorRunRow[]; rest: CollectorRunRow[]; ping: CollectorRunRow[]; traffic: CollectorRunRow[] }
const keyOf = (name: string) => name.trim().toLowerCase()
const record = (value: unknown): value is JsonRow => value != null && typeof value === "object" && !Array.isArray(value)
const finiteNonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0
export function sourcePeriodMs(seconds: number, minimumSeconds: number): number {
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? Math.max(minimumSeconds, seconds) * 1000 : NaN
}
export function probePeriodMs(config: CollectorSourceConfig, probe: ProbeIdentity): number {
const global = config.uptime?.probeIntervalSec ?? NaN
return Math.max(sourcePeriodMs(global, 1), probe.intervalSec > 0 ? sourcePeriodMs(probe.intervalSec, 0) : sourcePeriodMs(global, 5))
}
function parseRun(row: CollectorRunRow, job: string, field: "servers" | "probes"): Run | null {
let value = row.resultJson
if (typeof value === "string") { try { value = JSON.parse(value) } catch { return null } }
if (!record(value) || value.v !== 1 || value.job !== job || value.skipped === true || value.fatalError
|| typeof value.sampledAt !== "string" || !Array.isArray(value[field])) return null
const sampledMs = Date.parse(value.sampledAt), finishedMs = new Date(row.finishedAt).getTime()
if (!Number.isFinite(sampledMs) || !Number.isFinite(finishedMs)) return null
return { sampledAt: value.sampledAt, sampledMs, finishedMs, rows: value[field].filter(record) }
}
function fresh(run: Run, period: number, now: number, future = period): boolean {
return Number.isFinite(period) && period > 0 && [run.sampledMs, run.finishedMs].every(t => t >= now - 3 * period && t <= now + future)
}
function afterConfig(run: Run, ...times: Array<Date | string | undefined>): boolean {
return times.every(t => t === undefined || (Number.isFinite(new Date(t).getTime()) && run.sampledMs >= new Date(t).getTime()))
}
function identityRows(config: CollectorSourceConfig, rows: JsonRow[]) {
return createServerSignalIdentityGuard(config.servers)(rows as unknown as Array<{ serverId: number; name: string; host: string }>)
}
function serverSignals(config: CollectorSourceConfig, rows: CollectorRunRow[], rest: boolean, period: number, now: number): ServerSignal[] {
const runs = rows.map(row => parseRun(row, rest ? "servers_rest_ping" : "uptime_resources", "servers"))
const current = runs[0]
if (!current || !fresh(current, period, now)) return []
const status = (row: JsonRow): ServerSignal["status"] | null => rest
? (typeof row.ok === "boolean" ? row.ok ? "online" : "offline" : null)
: (typeof row.status === "string" && ["online", "offline", "degraded"].includes(row.status) ? row.status as ServerSignal["status"] : null)
const out: ServerSignal[] = []
for (const row of identityRows(config, current.rows)) {
const server = config.servers.find(s => Number(s.id) === row.serverId)!
if (!afterConfig(current, server.updatedAt)) continue
const states: ServerSignal["status"][] = []
for (const run of runs) {
if (!run || !fresh(run, period, now) || !afterConfig(run, server.updatedAt)) break
const matches = run.rows.filter(r => r.serverId === row.serverId)
if (!matches.length) continue
if (matches.length !== 1 || !identityRows(config, matches).length) break
const state = status(matches[0])
if (state === null) break
states.push(state)
if (states.length === 3) break
}
if (states.length) out.push({ name: row.name, status: states[0], prevStatus: states[1] ?? null, prev2Status: states[2] ?? null, sampledAt: current.sampledAt })
}
return out
}
function trafficSignals(config: CollectorSourceConfig, rows: CollectorRunRow[], now: number): TrafficServerSignal[] {
if (config.traffic?.enabled !== true) return []
const run = rows[0] && parseRun(rows[0], "traffic", "servers")
if (!run || !fresh(run, sourcePeriodMs(config.traffic.intervalSec, 5), now)) return []
const out: TrafficServerSignal[] = []
for (const row of identityRows(config, run.rows)) {
const raw = row as unknown as JsonRow
const server = config.servers.find(s => Number(s.id) === row.serverId)!
if (raw.ok !== true || !finiteNonnegative(raw.sumRxMbps) || !finiteNonnegative(raw.sumTxMbps) || !afterConfig(run, server.updatedAt)) continue
out.push({ serverName: row.name, rxMbps: raw.sumRxMbps, txMbps: raw.sumTxMbps, sampledAt: run.sampledAt })
}
return out
}
function probeSignals(config: CollectorSourceConfig, rows: CollectorRunRow[], now: number): ProbeSignal[] {
if ((config.uptime?.pingEnabled ?? config.uptime?.enabled) !== true) return []
const runs = rows.map(row => parseRun(row, "uptime_ping", "probes"))
const probes = config.probes.filter(p => p.enabled && config.servers.some(s => s.enabled === true && Number(s.id) === p.srcServerId))
const label = (p: ProbeIdentity) => `${p.name.trim()} → ${p.target.trim()}`
const counts = new Map<string, number>()
for (const probe of probes) counts.set(label(probe), (counts.get(label(probe)) ?? 0) + 1)
const out: ProbeSignal[] = []
for (const probe of probes) {
if (!probe.name.trim() || !probe.target.trim() || counts.get(label(probe)) !== 1) continue
const source = config.servers.find(s => Number(s.id) === probe.srcServerId)!
const period = probePeriodMs(config, probe), future = sourcePeriodMs(config.uptime!.probeIntervalSec, 1)
for (const run of runs) {
// Invalid completed runs are barriers; a valid due-only batch may omit this probe.
if (!run || !fresh(run, period, now, future) || !afterConfig(run, probe.updatedAt, source.updatedAt)) break
const matches = run.rows.filter(r => r.probeId === probe.id)
if (!matches.length) continue
if (matches.length !== 1) break
const row = matches[0]
if (row.name !== probe.name || row.target !== probe.target || row.srcServerId !== probe.srcServerId
|| row.srcInterface !== probe.srcInterface || row.srcServerName !== (source.name || source.host)
|| typeof row.status !== "string" || !["up", "warn", "down"].includes(row.status) || typeof row.ok !== "boolean"
|| !(row.rttMs === null || finiteNonnegative(row.rttMs)) || !finiteNonnegative(row.lossPct) || row.lossPct > 100) break
if (row.ok === false && (row.status !== "down" || row.rttMs !== null || row.lossPct !== 100)) break
out.push({ key: label(probe), status: row.status as ProbeSignal["status"], rttMs: row.rttMs as number | null, lossPct: row.lossPct, sampledAt: run.sampledAt })
break
}
}
return out
}
/** Missing/unknown data is never a fabricated zero or offline. */
export function collectSourceSignals(config: CollectorSourceConfig, runs: CollectorSourceRuns, now = Date.now()) {
const resourcesOn = (config.uptime?.resourcesEnabled ?? config.uptime?.enabled) === true
const resources = resourcesOn ? serverSignals(config, runs.resources, false, sourcePeriodMs(config.uptime!.intervalSec, 5), now) : []
const rest = config.rest?.enabled === true ? serverSignals(config, runs.rest, true, sourcePeriodMs(config.rest.intervalSec, 10), now) : []
const combined = new Map<string, ServerSignal>()
for (const signal of [...resources, ...rest]) {
const previous = combined.get(keyOf(signal.name))
// A fresh success confirms reachability; use its own history, never invent cross-source recovery.
if (!previous || (signal.status === "online" && previous.status !== "online")
|| ((signal.status === "online") === (previous.status === "online") && Date.parse(signal.sampledAt) >= Date.parse(previous.sampledAt))) combined.set(keyOf(signal.name), signal)
}
return { servers: [...combined.values()].sort((a, b) => a.name.localeCompare(b.name)), probes: probeSignals(config, runs.ping, now), trafficByServer: trafficSignals(config, runs.traffic, now) }
}
@@ -0,0 +1,88 @@
import assert from "node:assert/strict"
import { pool, closePool } from "../../db/index.js"
import { initDatabase } from "../../db/bootstrap.js"
import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js"
import { evaluateRule } from "./evaluate-rule.js"
import type { ApiAlertRule } from "../alerts-service.js"
const database = process.env.MMAPP_OWN_SQL_DB
if (!database) console.log("collector sources SQL: skipped; explicit owned database required")
else {
assert(/^mm_accept_166_\d+$/.test(database))
assert.equal(decodeURIComponent(new URL(process.env.DATABASE_URL!).pathname.slice(1)), database)
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name, database)
const prefix = "own-sources170-" + Date.now() + "-", now = Date.now(), iso = (delta = 0) => new Date(now + delta).toISOString()
let sequence = 0, cases = 0
const ids: number[] = []
const snapshot = (job: string, rows: unknown[], delta = -1_000) => ({ v: 1, job, sampledAt: iso(delta), [job === "uptime_ping" ? "probes" : "servers"]: rows })
const insert = async (job: string, payload: unknown, delta = -1_000, status = "ok") => {
await pool.query("INSERT INTO scheduler_runs(id,job_key,started_at,finished_at,status,error,result_json) VALUES($1,$2,$3,$3,$4,$5,$6::jsonb)", [prefix + sequence++, job, iso(delta), status, status === "error" ? "owned failure" : null, payload === null ? null : JSON.stringify(payload)])
}
const reset = () => pool.query("DELETE FROM scheduler_runs WHERE id LIKE $1", [prefix + "%"])
const build = () => buildSignalSnapshotFromCollectors(() => now)
try {
await initDatabase()
for (const index of [1, 2]) ids.push(Number((await pool.query("INSERT INTO servers(name,host,enabled,updated_at) VALUES($1,$2,true,$3) RETURNING id", [prefix + index, `own-host-${index}`, iso(-900_000)])).rows[0].id))
await pool.query("INSERT INTO uptime_settings(id,enabled,resources_enabled,ping_enabled,interval_sec,probe_interval_sec) VALUES(1,true,true,true,15,15) ON CONFLICT(id) DO UPDATE SET enabled=true,resources_enabled=true,ping_enabled=true,interval_sec=15,probe_interval_sec=15")
await pool.query("INSERT INTO servers_api_ping_settings(id,enabled,interval_sec) VALUES(1,false,120) ON CONFLICT(id) DO UPDATE SET enabled=false,interval_sec=120")
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")
for (const [index, interval] of [[0, 120], [1, 0]]) await pool.query("INSERT INTO uptime_probes(id,src_server_id,src_interface,name,target,enabled,interval_sec,updated_at) VALUES($1,$2,'ether1',$3,'own-target',true,$4,$5)", [prefix + "probe" + index, ids[index], prefix + "probe" + index, interval, iso(-900_000)])
const server = (index = 0) => ({ serverId: ids[index], name: prefix + (index + 1), host: `own-host-${index + 1}` })
const resource = (status = "offline", index = 0) => ({ ...server(index), status })
const traffic = { ...server(), ok: true, sumRxMbps: 0, sumTxMbps: 12 }
const probe = (index = 0) => ({ probeId: prefix + "probe" + index, name: prefix + "probe" + index, target: "own-target", srcServerId: ids[index], srcServerName: prefix + (index + 1), srcInterface: "ether1", ok: true, status: "up", rttMs: 5, lossPct: 0 })
await insert("uptime_resources", snapshot("uptime_resources", [resource("online")], -15_000), -15_000)
await insert("uptime_resources", snapshot("uptime_resources", [resource(), resource("online", 1)]), -1_000, "error")
await insert("traffic", snapshot("traffic", [traffic]))
await insert("uptime_ping", snapshot("uptime_ping", [probe()], -90_000), -90_000)
// More than the old 24-tick history; the slow probe must still survive due-only batches.
for (let index = 30; index > 0; index--) await insert("uptime_ping", snapshot("uptime_ping", [probe(1)], -index * 1_000), -index * 1_000)
let signals = await build()
assert.equal(signals.servers.length, 2); assert.equal(signals.servers[0].prevStatus, "online")
assert.equal(signals.trafficByServer[0].rxMbps, 0); assert.equal(signals.probes.length, 2); cases++
const offlineRule = { id: "owned", name: "owned", type: "server", targets: [server().name], conditions: ["offline"], enabled: true } as ApiAlertRule
assert.ok(evaluateRule(offlineRule, signals))
await pool.query("UPDATE uptime_settings SET resources_enabled=false,ping_enabled=false WHERE id=1")
await pool.query("UPDATE traffic_settings SET enabled=false WHERE id=1")
signals = await build(); assert.deepEqual(signals.servers, []); assert.deepEqual(signals.probes, []); assert.deepEqual(signals.trafficByServer, []); cases++
await pool.query("UPDATE uptime_settings SET resources_enabled=true,ping_enabled=true WHERE id=1")
await pool.query("UPDATE traffic_settings SET enabled=true WHERE id=1")
await pool.query("UPDATE servers SET enabled=false WHERE id=$1", [ids[0]])
signals = await build(); assert.equal(signals.servers.length, 1); assert.equal(signals.probes.length, 1); assert.deepEqual(signals.trafficByServer, []); cases++
await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [ids[0]])
await pool.query("UPDATE servers SET name=$1 WHERE id=$2", [server().name, ids[1]])
signals = await build(); assert.deepEqual(signals.servers, []); assert.deepEqual(signals.trafficByServer, []); cases++
await pool.query("UPDATE servers SET name=$1 WHERE id=$2", [server(1).name, ids[1]])
for (const broken of [null, "{", { ...snapshot("uptime_resources", []), servers: {} }, snapshot("uptime_resources", [])]) {
await reset(); await insert("uptime_resources", snapshot("uptime_resources", [resource()], -15_000), -15_000)
await insert("uptime_resources", broken, -1_000, "error")
signals = await build(); assert.deepEqual(signals.servers, []); assert.equal(evaluateRule(offlineRule, signals), null); cases++
}
for (const [delta, field] of [[-46_000, "sample"], [16_000, "sample"], [-46_000, "finish"], [16_000, "finish"]] as const) {
await reset(); await insert("uptime_resources", snapshot("uptime_resources", [resource()], field === "sample" ? delta : -1_000), field === "finish" ? delta : -1_000)
assert.deepEqual((await build()).servers, []); cases++
}
await reset(); await insert("uptime_resources", snapshot("uptime_resources", [resource("unknown"), resource("online", 1)]), -1_000, "error")
signals = await build(); assert.equal(signals.servers.length, 1); assert.equal(signals.servers[0].name, server(1).name); cases++
await reset(); await insert("traffic", snapshot("traffic", [traffic], -30_000), -30_000)
await insert("traffic", snapshot("traffic", [{ ...server(), ok: false }]), -1_000, "error")
assert.deepEqual((await build()).trafficByServer, []); cases++
await reset(); await insert("uptime_ping", snapshot("uptime_ping", [probe()], -15_000), -15_000)
await insert("uptime_ping", null, -1_000, "error")
assert.deepEqual((await build()).probes, []); cases++
await reset(); await insert("uptime_ping", snapshot("uptime_ping", [probe()], -15_000), -15_000)
await pool.query("UPDATE uptime_probes SET updated_at=$1 WHERE id=$2", [iso(), probe().probeId])
assert.deepEqual((await build()).probes, []); cases++
console.log(`collector sources owned SQL: ${cases} cases; real settings/identity/error/time/due-only/unknown/reconfiguration/evaluation passed`)
} finally {
await reset()
await pool.query("DELETE FROM uptime_probes WHERE id LIKE $1", [prefix + "%"])
await pool.query("DELETE FROM servers WHERE id = ANY($1::bigint[])", [ids])
assert.equal((await pool.query("SELECT count(*)::int AS n FROM scheduler_runs WHERE id LIKE $1", [prefix + "%"])).rows[0].n, 0)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers WHERE id=ANY($1::bigint[])", [ids])).rows[0].n, 0)
await closePool()
}
}
@@ -20,6 +20,8 @@ assert.deepEqual(guard([sample(1), sample(1)]), [])
assert.deepEqual(guard([{ ...sample(1), name: " chr " }]), [{ ...sample(1), name: " chr " }])
assert.deepEqual(createServerSignalIdentityGuard([{ ...server(1), id: "1" }])([sample(1)]), [sample(1)])
assert.deepEqual(createServerSignalIdentityGuard([{ ...server(1), id: "9007199254740993" }])([sample(1)]), [])
assert.deepEqual(createServerSignalIdentityGuard([server(1, "")])([sample(1, "host-1")]), [sample(1, "host-1")])
assert.deepEqual(createServerSignalIdentityGuard([server(1, ""), server(2, "host-1")])([sample(1, "host-1")]), [])
// Filtering is immutable, and checks every run against the same current inventory.
const rows = [sample(1), sample(2)]
const before = JSON.stringify(rows)
@@ -8,19 +8,20 @@ export interface AlertServerIdentity {
interface SnapshotServerIdentity { serverId: number; name: string; host: string }
const nameKey = (name: string) => name.trim().toLowerCase()
const displayName = (server: AlertServerIdentity) => server.name || server.host
/** Rules currently address servers by name. Ambiguous names must never select an arbitrary device. */
export function createServerSignalIdentityGuard(servers: AlertServerIdentity[]) {
const active = servers.filter(s => s.enabled === true)
const nameCounts = new Map<string, number>()
for (const server of active) {
const key = nameKey(server.name)
const key = nameKey(displayName(server))
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1)
}
const eligible = new Map<number, AlertServerIdentity>()
for (const server of active) {
const id = Number(server.id)
const key = nameKey(server.name)
const key = nameKey(displayName(server))
if (Number.isSafeInteger(id) && id > 0 && key && nameCounts.get(key) === 1) eligible.set(id, server)
}
return <T extends SnapshotServerIdentity>(rows: T[]): T[] => {
@@ -30,7 +31,7 @@ export function createServerSignalIdentityGuard(servers: AlertServerIdentity[])
if (!row || counts.get(row.serverId) !== 1) return false
const server = eligible.get(row.serverId)
return server != null && typeof row.name === "string" && typeof row.host === "string"
&& nameKey(row.name) === nameKey(server.name) && row.host.trim() === server.host.trim()
&& nameKey(row.name) === nameKey(displayName(server)) && row.host.trim() === server.host.trim()
})
}
}
@@ -2,226 +2,40 @@ import { dbAll } from "../../db/index.js"
import { collectBgpSignalsFromRuns } from "./bgp-signals.js"
import { collectGreSignalsFromRuns } from "./gre-signals.js"
import { readGreBgpRunSnapshots } from "../gre-bgp-snapshot-history.js"
import { createServerSignalIdentityGuard, type AlertServerIdentity } from "./server-signal-identity.js"
import type {
PingRunSnapshot,
ResourcesRunSnapshot,
SchedulerRunSnapshot,
ServersRestPingRunSnapshot,
TrafficRunSnapshot,
} from "../../types/scheduler-run-snapshot.js"
import type {
ProbeSignal,
ServerSignal,
SignalSnapshot,
TrafficServerSignal,
} from "./types.js"
import { collectSourceSignals, probePeriodMs, type CollectorRunRow, type CollectorSourceConfig } from "./collector-source-signals.js"
import type { SignalSnapshot } from "./types.js"
function normalizeNameKey(v: string): string {
return v.trim().toLowerCase()
async function readRuns(job: string, limit: number, since?: string): Promise<CollectorRunRow[]> {
return dbAll<CollectorRunRow>(`SELECT result_json AS "resultJson", finished_at AS "finishedAt"
FROM scheduler_runs WHERE job_key = ? AND status IN ('ok', 'error')
${since ? "AND finished_at >= ?" : ""}
ORDER BY finished_at DESC, id DESC LIMIT ?`, since ? [job, since, limit] : [job, limit])
}
function safeParseRunSnapshot(raw: unknown): SchedulerRunSnapshot | null {
const obj = typeof raw === "string" ? (() => {
try { return JSON.parse(raw) as unknown } catch { return null }
})() : raw
if (!obj || typeof obj !== "object") return null
return obj as SchedulerRunSnapshot
}
async function readLatestOkRunSnapshots(jobKey: string, limit = 32): Promise<SchedulerRunSnapshot[]> {
const rows = await dbAll<{ resultJson: unknown }>(
`SELECT result_json AS "resultJson"
FROM scheduler_runs
WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL
ORDER BY finished_at DESC
LIMIT ?`,
[jobKey, limit],
)
const out: SchedulerRunSnapshot[] = []
for (const row of rows) {
const parsed = safeParseRunSnapshot(row.resultJson)
if (!parsed || parsed.job !== jobKey) continue
out.push(parsed)
}
return out
}
type TriState = "online" | "offline" | "degraded"
function timelineToServerSignal(name: string, sampledAt: string, statuses: TriState[]): ServerSignal {
/** Read-only: do not seed defaults or load credentials while building alert facts. */
export async function buildSignalSnapshotFromCollectors(clock: () => number = Date.now): Promise<SignalSnapshot> {
const now = clock()
const [servers, probes, uptime, rest, traffic] = await Promise.all([
dbAll<CollectorSourceConfig["servers"][number]>("SELECT id, name, host, enabled, updated_at AS \"updatedAt\" FROM servers"),
dbAll<CollectorSourceConfig["probes"][number]>(`SELECT id, name, target, src_server_id AS "srcServerId", src_interface AS "srcInterface",
enabled, interval_sec AS "intervalSec", updated_at AS "updatedAt" FROM uptime_probes`),
dbAll<NonNullable<CollectorSourceConfig["uptime"]>>(`SELECT enabled, resources_enabled AS "resourcesEnabled", ping_enabled AS "pingEnabled",
interval_sec AS "intervalSec", probe_interval_sec AS "probeIntervalSec" FROM uptime_settings WHERE id = 1`),
dbAll<NonNullable<CollectorSourceConfig["rest"]>>('SELECT enabled, interval_sec AS "intervalSec" FROM servers_api_ping_settings WHERE id = 1'),
dbAll<NonNullable<CollectorSourceConfig["traffic"]>>('SELECT enabled, interval_sec AS "intervalSec" FROM traffic_settings WHERE id = 1'),
])
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)
const probeWindowMs = 3 * Math.max(5_000, ...periods)
const [resourceRuns, restRuns, pingRuns, trafficRuns, greBgpRuns] = await Promise.all([
readRuns("uptime_resources", 16), readRuns("servers_rest_ping", 16),
// Due-only batches: cover the longest configured TTL rather than only the last 24 ticks.
readRuns("uptime_ping", 4096, new Date(now - probeWindowMs).toISOString()),
readRuns("traffic", 8), readGreBgpRunSnapshots(undefined, clock),
])
return {
name,
status: statuses[0] ?? "offline",
prevStatus: statuses[1] ?? null,
prev2Status: statuses[2] ?? null,
sampledAt,
}
}
function collectServerSignalsFromResourceRuns(runs: ResourcesRunSnapshot[]): Map<string, ServerSignal> {
const byKey = new Map<
string,
{ name: string; sampledAt: string; statuses: TriState[] }
>()
for (const run of runs) {
for (const s of run.servers ?? []) {
const name = String(s.name ?? "").trim()
if (!name) continue
const key = normalizeNameKey(name)
const rec = byKey.get(key) ?? { name, sampledAt: run.sampledAt, statuses: [] }
if (rec.statuses.length < 3) rec.statuses.push(s.status === "online" ? "online" : "offline")
if (!byKey.has(key)) rec.sampledAt = run.sampledAt
byKey.set(key, rec)
}
}
const out = new Map<string, ServerSignal>()
for (const [key, rec] of byKey) out.set(key, timelineToServerSignal(rec.name, rec.sampledAt, rec.statuses))
return out
}
function collectServerSignalsFromRestRuns(runs: ServersRestPingRunSnapshot[]): Map<string, ServerSignal> {
const byKey = new Map<
string,
{ name: string; sampledAt: string; statuses: TriState[] }
>()
for (const run of runs) {
for (const s of run.servers ?? []) {
const name = String(s.name ?? "").trim()
if (!name) continue
const key = normalizeNameKey(name)
const rec = byKey.get(key) ?? { name, sampledAt: run.sampledAt, statuses: [] }
if (rec.statuses.length < 3) rec.statuses.push(s.ok ? "online" : "offline")
if (!byKey.has(key)) rec.sampledAt = run.sampledAt
byKey.set(key, rec)
}
}
const out = new Map<string, ServerSignal>()
for (const [key, rec] of byKey) out.set(key, timelineToServerSignal(rec.name, rec.sampledAt, rec.statuses))
return out
}
function mergeResourceAndRestServer(resource: ServerSignal, rest: ServerSignal): ServerSignal {
const rUp = resource.status === "online"
const tUp = rest.status === "online"
if (tUp && !rUp) {
const rBad = resource.status === "offline" || resource.status === "degraded"
if (rBad) {
return {
name: rest.name,
status: "online",
prevStatus: "online",
prev2Status: resource.status,
sampledAt: rest.sampledAt,
}
}
return rest
}
if (rUp && !tUp) return resource
if (tUp && rUp) {
if (rest.sampledAt >= resource.sampledAt) {
if (resource.prevStatus === "offline" || resource.prevStatus === "degraded") {
return {
name: rest.name,
status: "online",
prevStatus: "online",
prev2Status: resource.prevStatus === "degraded" ? "degraded" : "offline",
sampledAt: rest.sampledAt,
}
}
if (
resource.prevStatus === "online" &&
resource.prev2Status != null &&
(resource.prev2Status === "offline" || resource.prev2Status === "degraded")
) {
return {
name: rest.name,
status: "online",
prevStatus: "online",
prev2Status: resource.prev2Status,
sampledAt: rest.sampledAt,
}
}
}
return rest.sampledAt >= resource.sampledAt ? rest : resource
}
return rest.sampledAt >= resource.sampledAt ? rest : resource
}
function collectProbeSignalsFromRuns(runs: PingRunSnapshot[]): ProbeSignal[] {
const out = new Map<string, ProbeSignal>()
for (const run of runs) {
for (const p of run.probes ?? []) {
const key = `${String(p.name ?? "").trim()} → ${String(p.target ?? "").trim()}`
if (!key.trim() || out.has(key)) continue
out.set(key, {
key,
status: p.status,
rttMs: p.rttMs ?? null,
lossPct: p.lossPct ?? null,
sampledAt: run.sampledAt,
})
}
}
return [...out.values()]
}
function collectTrafficSignalsFromRuns(runs: TrafficRunSnapshot[]): TrafficServerSignal[] {
const out = new Map<string, TrafficServerSignal>()
for (const run of runs) {
for (const s of run.servers ?? []) {
const name = String(s.name ?? "").trim()
if (!name) continue
const key = normalizeNameKey(name)
if (out.has(key)) continue
out.set(key, {
serverName: name,
rxMbps: Number(s.sumRxMbps ?? 0),
txMbps: Number(s.sumTxMbps ?? 0),
sampledAt: run.sampledAt,
})
}
}
return [...out.values()]
}
/** Собирает сигналы alert_engine напрямую из результатов collector jobs (`scheduler_runs.result_json`). */
export async function buildSignalSnapshotFromCollectors(): Promise<SignalSnapshot> {
const sampledAt = new Date().toISOString()
// Read only identity/configuration; credentials are not needed by the alert engine.
const currentServers = await dbAll<AlertServerIdentity>("SELECT id, name, host, enabled FROM servers")
const guardServerRows = createServerSignalIdentityGuard(currentServers)
const resourceRuns = await readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[]
const restRuns = await readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[]
const pingRuns = await readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[]
const trafficRuns = await readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[]
const greBgpRuns = await readGreBgpRunSnapshots()
const resourceByKey = collectServerSignalsFromResourceRuns(resourceRuns.map(run => ({ ...run, servers: guardServerRows(run.servers ?? []) })))
const restByKey = collectServerSignalsFromRestRuns(restRuns.map(run => ({ ...run, servers: guardServerRows(run.servers ?? []) })))
const serverKeys = new Set([...resourceByKey.keys(), ...restByKey.keys()])
const servers: ServerSignal[] = []
for (const key of serverKeys) {
const resource = resourceByKey.get(key)
const rest = restByKey.get(key)
if (!rest) {
if (resource) servers.push(resource)
continue
}
if (!resource) {
servers.push(rest)
continue
}
servers.push(mergeResourceAndRestServer(resource, rest))
}
servers.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }))
return {
sampledAt,
servers,
probes: collectProbeSignalsFromRuns(pingRuns),
trafficByServer: collectTrafficSignalsFromRuns(trafficRuns.map(run => ({ ...run, servers: guardServerRows(run.servers ?? []) }))),
greTunnels: collectGreSignalsFromRuns(greBgpRuns),
bgpSessions: collectBgpSignalsFromRuns(greBgpRuns),
sampledAt: new Date(now).toISOString(),
...collectSourceSignals(config, { resources: resourceRuns, rest: restRuns, ping: pingRuns, traffic: trafficRuns }, now),
greTunnels: collectGreSignalsFromRuns(greBgpRuns), bgpSessions: collectBgpSignalsFromRuns(greBgpRuns),
}
}
+20 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: протокол внешней приёмки 12.5
Актуальное продолжение 2026-10-12: завершён локальный блок 12.5.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;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.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;12.5 открыт.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
@@ -1890,3 +1890,21 @@ flowchart LR
Ограничения сохранены явно: only-ok history всё ещё может поднимать старый факт, disabled flags самих jobs и sampledAt age не применяются; resource unknown и traffic отсутствующее значение требуют отдельного исправления; probe identity/enabled/individual interval остаются в170. Duplicate row guard не заменяет latest-current/error barrier. SQL/live fixtures и source/runtime parity для169 отсутствуют; удалённый backend/frontend не развёртывались, RouterOS writes/messages0, новые сетевые объекты не создавались. Доступность серверов в этом локальном блоке не перепроверялась. Runtime остаётся ux-fresh167/ux-bfd158; изменений169 в браузере пока нет.
Протокол: [локальная проверка169](ux-ui/acceptance/alert-server-identity-2026-10-12.json). Следующий170 — source enabled/freshness/current known guards, per-probe timing и identity с owned SQL positive/negative fixtures, затем coherent backend build/deploy/live и локальный коммит без push. Этап12.5 остаётся открытым.
## 12.5.170 — достоверность серверных, пробных и трафиковых сигналов (2026-10-12)
Завершён оставшийся объём169 в одном coherent backend блоке. Новый collector-source-signals отделяет чтение от чистого построения фактов. Builder читает текущие настройки и identity без seeding getters и без credentials: resourcesEnabled/pingEnabled имеют тот же fallback на legacy enabled, что scheduler; REST/traffic используют собственные enabled. Выбираются последние завершённые ok/error runs, включая NULL/error JSON, вместо only-ok history. Текущие server/traffic keys берутся только из newest run: empty/error/malformed/unknown не восстанавливают старую цель. Частичная ошибочная коллекция сохраняет известные строки других устройств. ServerID/name/host/enabled/duplicate guards169 сохранены; пустое имя может использовать host, как collector. producer sample и completion проверяются по3 effective periods, future tolerance1 period; минимумы5s resources/traffic и10s REST повторяют scheduler. Resource неизвестное значение не становится offline; Traffic требует ok=true и конечные неотрицательные RX/TX, отсутствующее/ошибка не становится0. Подтверждённый нулевой трафик остаётся0.
Ping сопоставляется с текущим probeId/name/target/sourceServerId/interface/source label, enabled probe и enabled source. Повторные цели/строки исключаются; более новое unknown/null/fatal/error observation не подменяется старым. Valid due-only batch может не содержать медленную пробу: её latest observed row сохраняется в пределах своего TTL. Effective probe period учитывает individual interval и глобальный tick, default минимум5s; future tolerance привязана к глобальному tick. Чтение охватывает3 максимальных настроенных probe periods, с защитным пределом4096 rows (при очень большом масштабе возможен консервативный пропуск, масштаб этим блоком не принят). Сэмплы до server/probe updatedAt не подтверждают новую конфигурацию. Server timelines прекращают recovery history на unknown/invalid/ambiguous barrier; свежие resource/REST подтверждения объединяются без выдуманного cross-source recovery. Выбирается реальное собственное history успешного источника.
Постоянные чистые тесты проверяют config/identity/time/empty/error/partial/unknown/numeric boundaries, медленные due probes, изменения конфигурации, recovery и настоящий evaluateRule путь с положительным и отрицательным control (server offline, loss и RX low). Локальные alert-engine и scheduler profiles прошли, backend build и ESLint прошли. Локальный PostgreSQL недоступен, SQL branches там skipped; положительная SQL-приёмка выполнена отдельно на Node22/PostgreSQL18.6: test:alert-engine за18622ms,16 новых SQL cases (включая31 Ping batches, больше прежнего лимита24),8 GRE/BGP SQL rows и actual confirm-stability passed без PG skips.
Автоматическая проверка отклонила копирование всех production env в persistent файл; этот способ не использован. Для SQL созданы собственная база mm_accept_166_1791791913287 и отдельная LOGIN роль без SUPERUSER/CREATEDB/CREATEROLE/BYPASSRLS, пароль сгенерирован временно и не публиковался. URL/роль ограничены own DB; main credentials/JWT не копировались. Тестовые строки/контейнер, база и роль удалены и отсутствие проверено. Main SQL fixtures/RouterOS/messages отсутствуют; обычные фоновые сборщики main не останавливались. Полная24-profile/UI1260/context221 regression157/166 без новой причины не повторялась.
Production backend собран инкрементально на прежней базе ux-fresh167 с прежними зависимостями: backend dependency hash совпал.433 source inputs и1008 emitted files совпали поSHA256 с LF normalization, запущенный imageId совпал с проверенным образом. Развёрнут ux-sources170/version1.0.0-test.sources170, backend/PG healthy; frontend ux-bfd158 прежний. В.env изменён только BACKEND_IMAGE_TAG, для rollback сохранён прежний tag ux-fresh167, копия всего secret env не создавалась. После cleanup только3 основных running containers.
Readonly production PG builder подтвердил1 группу одинаковых enabled labels и0 server/traffic/probe signals. Ноль проб соответствует отсутствию configured probes, server/traffic подавлены из-за неоднозначности двух CHR с одним именем; это не отказ устройств и не доказательство положительных измерений (они проверены в owned SQL). Имена/пароли не менялись.4 read-only API checks включают оба selected CHR BGP endpoints и catalog без failures;6 UI cases /alerts,/servers,/uptime при1440/320 passed, pageerrors/API writes/overflow0. Первые локальные network calls получили sandbox EACCES, повтор с разрешённым network доступом passed; UI helper сначала ожидал отсутствующий в существующей странице h1, исправлен на фактический main без изменения frontend. Это scoped smoke, не новая полная accessibility сертификация.
Протоколы: [SQL16/cleanup](ux-ui/acceptance/collector-sources-sql-2026-10-12.json), [source433](ux-ui/acceptance/collector-sources-source-2026-10-12.json), [compiled1008](ux-ui/acceptance/collector-sources-compiled-2026-10-12.json), [readonly production](ux-ui/acceptance/collector-sources-production-2026-10-12.json), [runtime](ux-ui/acceptance/collector-sources-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/collector-sources-deployed-2026-10-12.json).
Следующий171 — понятная диагностика неоднозначных/отключённых целей оповещений: silent suppression безопасен для engine, но пользователь должен понимать причину отсутствия события. Миграция name-based rules на stable ID, scale/multi-instance/physical WAN/внешние Telegram/Portal/client OS остаются отдельными условиями12.5. Локальный коммит без push.
+3 -3
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-12: завершён локальный блок 12.5.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;12.5 открыт.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-12. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-12
Актуальное продолжение 2026-10-12: завершён локальный блок 12.5.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;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.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;12.5 открыт.
Подробный объём, протоколы, схема и ограничения: [12.5.77 в журнале](ux-ui-progress.md).
+19 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-12: завершён локальный блок 12.5.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;12.5 открыт.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -38,6 +38,24 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–168 и ограничения ниже. Runtime backend ux-month149/frontend ux-sessions150. |
## 12.5.170 — достоверность серверных, пробных и трафиковых сигналов (2026-10-12)
Завершён оставшийся объём169 в одном coherent backend блоке. Новый collector-source-signals отделяет чтение от чистого построения фактов. Builder читает текущие настройки и identity без seeding getters и без credentials: resourcesEnabled/pingEnabled имеют тот же fallback на legacy enabled, что scheduler; REST/traffic используют собственные enabled. Выбираются последние завершённые ok/error runs, включая NULL/error JSON, вместо only-ok history. Текущие server/traffic keys берутся только из newest run: empty/error/malformed/unknown не восстанавливают старую цель. Частичная ошибочная коллекция сохраняет известные строки других устройств. ServerID/name/host/enabled/duplicate guards169 сохранены; пустое имя может использовать host, как collector. producer sample и completion проверяются по3 effective periods, future tolerance1 period; минимумы5s resources/traffic и10s REST повторяют scheduler. Resource неизвестное значение не становится offline; Traffic требует ok=true и конечные неотрицательные RX/TX, отсутствующее/ошибка не становится0. Подтверждённый нулевой трафик остаётся0.
Ping сопоставляется с текущим probeId/name/target/sourceServerId/interface/source label, enabled probe и enabled source. Повторные цели/строки исключаются; более новое unknown/null/fatal/error observation не подменяется старым. Valid due-only batch может не содержать медленную пробу: её latest observed row сохраняется в пределах своего TTL. Effective probe period учитывает individual interval и глобальный tick, default минимум5s; future tolerance привязана к глобальному tick. Чтение охватывает3 максимальных настроенных probe periods, с защитным пределом4096 rows (при очень большом масштабе возможен консервативный пропуск, масштаб этим блоком не принят). Сэмплы до server/probe updatedAt не подтверждают новую конфигурацию. Server timelines прекращают recovery history на unknown/invalid/ambiguous barrier; свежие resource/REST подтверждения объединяются без выдуманного cross-source recovery. Выбирается реальное собственное history успешного источника.
Постоянные чистые тесты проверяют config/identity/time/empty/error/partial/unknown/numeric boundaries, медленные due probes, изменения конфигурации, recovery и настоящий evaluateRule путь с положительным и отрицательным control (server offline, loss и RX low). Локальные alert-engine и scheduler profiles прошли, backend build и ESLint прошли. Локальный PostgreSQL недоступен, SQL branches там skipped; положительная SQL-приёмка выполнена отдельно на Node22/PostgreSQL18.6: test:alert-engine за18622ms,16 новых SQL cases (включая31 Ping batches, больше прежнего лимита24),8 GRE/BGP SQL rows и actual confirm-stability passed без PG skips.
Автоматическая проверка отклонила копирование всех production env в persistent файл; этот способ не использован. Для SQL созданы собственная база mm_accept_166_1791791913287 и отдельная LOGIN роль без SUPERUSER/CREATEDB/CREATEROLE/BYPASSRLS, пароль сгенерирован временно и не публиковался. URL/роль ограничены own DB; main credentials/JWT не копировались. Тестовые строки/контейнер, база и роль удалены и отсутствие проверено. Main SQL fixtures/RouterOS/messages отсутствуют; обычные фоновые сборщики main не останавливались. Полная24-profile/UI1260/context221 regression157/166 без новой причины не повторялась.
Production backend собран инкрементально на прежней базе ux-fresh167 с прежними зависимостями: backend dependency hash совпал.433 source inputs и1008 emitted files совпали поSHA256 с LF normalization, запущенный imageId совпал с проверенным образом. Развёрнут ux-sources170/version1.0.0-test.sources170, backend/PG healthy; frontend ux-bfd158 прежний. В.env изменён только BACKEND_IMAGE_TAG, для rollback сохранён прежний tag ux-fresh167, копия всего secret env не создавалась. После cleanup только3 основных running containers.
Readonly production PG builder подтвердил1 группу одинаковых enabled labels и0 server/traffic/probe signals. Ноль проб соответствует отсутствию configured probes, server/traffic подавлены из-за неоднозначности двух CHR с одним именем; это не отказ устройств и не доказательство положительных измерений (они проверены в owned SQL). Имена/пароли не менялись.4 read-only API checks включают оба selected CHR BGP endpoints и catalog без failures;6 UI cases /alerts,/servers,/uptime при1440/320 passed, pageerrors/API writes/overflow0. Первые локальные network calls получили sandbox EACCES, повтор с разрешённым network доступом passed; UI helper сначала ожидал отсутствующий в существующей странице h1, исправлен на фактический main без изменения frontend. Это scoped smoke, не новая полная accessibility сертификация.
Протоколы: [SQL16/cleanup](ux-ui/acceptance/collector-sources-sql-2026-10-12.json), [source433](ux-ui/acceptance/collector-sources-source-2026-10-12.json), [compiled1008](ux-ui/acceptance/collector-sources-compiled-2026-10-12.json), [readonly production](ux-ui/acceptance/collector-sources-production-2026-10-12.json), [runtime](ux-ui/acceptance/collector-sources-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/collector-sources-deployed-2026-10-12.json).
Следующий171 — понятная диагностика неоднозначных/отключённых целей оповещений: silent suppression безопасен для engine, но пользователь должен понимать причину отсутствия события. Миграция name-based rules на stable ID, scale/multi-instance/physical WAN/внешние Telegram/Portal/client OS остаются отдельными условиями12.5. Локальный коммит без push.
## 12.5.169 — идентификация устройств в сигналах оповещений (2026-10-12)
По поручению пользователя выполнить небольшой объём при оставшихся7% лимита. Исходный единый объём169 ограничен самостоятельной локальной частью; полная приёмка источников не заявляется. Добавлен server-signal-identity.ts, builder читает только id/name/host/enabled из текущего servers. До name-based timeline и traffic reducer каждая строка resource/REST/traffic сопоставляется с текущим активным устройством по ID, имени и адресу. Сигналы отключённых/удалённых устройств, старого имени/адреса и повторяющегося ID внутри одного run исключаются. Если несколько enabled устройств имеют одинаковое имя после trim/lowercase, их сигналы не выдаются: существующие правила адресуют цель по имени и не позволяют безопасно выбрать конкретное устройство. Имена/настройки самих CHR не менялись. На текущем стенде оба CHR имеют одинаковое имя; после будущего развёртывания такие цели будут подавлены до явного устранения неоднозначности или миграции правил на ID. Это консервативное отсутствие подтверждённого события, не offline и не нулевой traffic.
+21 -3
View File
@@ -1,8 +1,8 @@
# Оставшаяся внешняя приёмка — 2026-10-12
Это актуальная матрица после 12.5.169. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Это актуальная матрица после 12.5.170. Исторические записи сохраняют версии и ограничения на момент выполнения; поздние проверки ниже уточняют их. Этап 12.5 открыт. Успех в двух CHR и одном backend не означает приёмку всех конфигураций, интеграций или полной регрессии.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-fresh167`, frontend `ux-bfd158`; production lab использует AUTH_REQUIRED=false. Подписанные JWT/RBAC проверялись в изолированных процессах. Все собственные объекты112 удалены, исходные13 коллекций×2 (включая logging/certificates/files/routes) проверены; пароли не менялись.
Стенд: Debian13/Compose, PostgreSQL18.6, два CHR RouterOS7.24.5. Backend `ux-sources170`, frontend `ux-bfd158`; 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/freshness fix170 pending; последующие изменения проверять отдельно; 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 pending; последующие изменения проверять отдельно; 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). Невыполненные пункты не являются все блокерами данного лабораторного сценария: для выпуска необходимо явно определить требуемые конфигурации и принять их либо согласовать ограничение.
@@ -805,3 +805,21 @@ flowchart LR
Ограничения сохранены явно: only-ok history всё ещё может поднимать старый факт, disabled flags самих jobs и sampledAt age не применяются; resource unknown и traffic отсутствующее значение требуют отдельного исправления; probe identity/enabled/individual interval остаются в170. Duplicate row guard не заменяет latest-current/error barrier. SQL/live fixtures и source/runtime parity для169 отсутствуют; удалённый backend/frontend не развёртывались, RouterOS writes/messages0, новые сетевые объекты не создавались. Доступность серверов в этом локальном блоке не перепроверялась. Runtime остаётся ux-fresh167/ux-bfd158; изменений169 в браузере пока нет.
Протокол: [локальная проверка169](ux-ui/acceptance/alert-server-identity-2026-10-12.json). Следующий170 — source enabled/freshness/current known guards, per-probe timing и identity с owned SQL positive/negative fixtures, затем coherent backend build/deploy/live и локальный коммит без push. Этап12.5 остаётся открытым.
## 12.5.170 — достоверность серверных, пробных и трафиковых сигналов (2026-10-12)
Завершён оставшийся объём169 в одном coherent backend блоке. Новый collector-source-signals отделяет чтение от чистого построения фактов. Builder читает текущие настройки и identity без seeding getters и без credentials: resourcesEnabled/pingEnabled имеют тот же fallback на legacy enabled, что scheduler; REST/traffic используют собственные enabled. Выбираются последние завершённые ok/error runs, включая NULL/error JSON, вместо only-ok history. Текущие server/traffic keys берутся только из newest run: empty/error/malformed/unknown не восстанавливают старую цель. Частичная ошибочная коллекция сохраняет известные строки других устройств. ServerID/name/host/enabled/duplicate guards169 сохранены; пустое имя может использовать host, как collector. producer sample и completion проверяются по3 effective periods, future tolerance1 period; минимумы5s resources/traffic и10s REST повторяют scheduler. Resource неизвестное значение не становится offline; Traffic требует ok=true и конечные неотрицательные RX/TX, отсутствующее/ошибка не становится0. Подтверждённый нулевой трафик остаётся0.
Ping сопоставляется с текущим probeId/name/target/sourceServerId/interface/source label, enabled probe и enabled source. Повторные цели/строки исключаются; более новое unknown/null/fatal/error observation не подменяется старым. Valid due-only batch может не содержать медленную пробу: её latest observed row сохраняется в пределах своего TTL. Effective probe period учитывает individual interval и глобальный tick, default минимум5s; future tolerance привязана к глобальному tick. Чтение охватывает3 максимальных настроенных probe periods, с защитным пределом4096 rows (при очень большом масштабе возможен консервативный пропуск, масштаб этим блоком не принят). Сэмплы до server/probe updatedAt не подтверждают новую конфигурацию. Server timelines прекращают recovery history на unknown/invalid/ambiguous barrier; свежие resource/REST подтверждения объединяются без выдуманного cross-source recovery. Выбирается реальное собственное history успешного источника.
Постоянные чистые тесты проверяют config/identity/time/empty/error/partial/unknown/numeric boundaries, медленные due probes, изменения конфигурации, recovery и настоящий evaluateRule путь с положительным и отрицательным control (server offline, loss и RX low). Локальные alert-engine и scheduler profiles прошли, backend build и ESLint прошли. Локальный PostgreSQL недоступен, SQL branches там skipped; положительная SQL-приёмка выполнена отдельно на Node22/PostgreSQL18.6: test:alert-engine за18622ms,16 новых SQL cases (включая31 Ping batches, больше прежнего лимита24),8 GRE/BGP SQL rows и actual confirm-stability passed без PG skips.
Автоматическая проверка отклонила копирование всех production env в persistent файл; этот способ не использован. Для SQL созданы собственная база mm_accept_166_1791791913287 и отдельная LOGIN роль без SUPERUSER/CREATEDB/CREATEROLE/BYPASSRLS, пароль сгенерирован временно и не публиковался. URL/роль ограничены own DB; main credentials/JWT не копировались. Тестовые строки/контейнер, база и роль удалены и отсутствие проверено. Main SQL fixtures/RouterOS/messages отсутствуют; обычные фоновые сборщики main не останавливались. Полная24-profile/UI1260/context221 regression157/166 без новой причины не повторялась.
Production backend собран инкрементально на прежней базе ux-fresh167 с прежними зависимостями: backend dependency hash совпал.433 source inputs и1008 emitted files совпали поSHA256 с LF normalization, запущенный imageId совпал с проверенным образом. Развёрнут ux-sources170/version1.0.0-test.sources170, backend/PG healthy; frontend ux-bfd158 прежний. В.env изменён только BACKEND_IMAGE_TAG, для rollback сохранён прежний tag ux-fresh167, копия всего secret env не создавалась. После cleanup только3 основных running containers.
Readonly production PG builder подтвердил1 группу одинаковых enabled labels и0 server/traffic/probe signals. Ноль проб соответствует отсутствию configured probes, server/traffic подавлены из-за неоднозначности двух CHR с одним именем; это не отказ устройств и не доказательство положительных измерений (они проверены в owned SQL). Имена/пароли не менялись.4 read-only API checks включают оба selected CHR BGP endpoints и catalog без failures;6 UI cases /alerts,/servers,/uptime при1440/320 passed, pageerrors/API writes/overflow0. Первые локальные network calls получили sandbox EACCES, повтор с разрешённым network доступом passed; UI helper сначала ожидал отсутствующий в существующей странице h1, исправлен на фактический main без изменения frontend. Это scoped smoke, не новая полная accessibility сертификация.
Протоколы: [SQL16/cleanup](ux-ui/acceptance/collector-sources-sql-2026-10-12.json), [source433](ux-ui/acceptance/collector-sources-source-2026-10-12.json), [compiled1008](ux-ui/acceptance/collector-sources-compiled-2026-10-12.json), [readonly production](ux-ui/acceptance/collector-sources-production-2026-10-12.json), [runtime](ux-ui/acceptance/collector-sources-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/collector-sources-deployed-2026-10-12.json).
Следующий171 — понятная диагностика неоднозначных/отключённых целей оповещений: silent suppression безопасен для engine, но пользователь должен понимать причину отсутствия события. Миграция name-based rules на stable ID, scale/multi-instance/physical WAN/внешние Telegram/Portal/client OS остаются отдельными условиями12.5. Локальный коммит без push.
+19 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-12: завершён локальный блок 12.5.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Актуальное продолжение 2026-10-12: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;12.5 открыт.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -3195,3 +3195,21 @@ flowchart LR
Ограничения сохранены явно: only-ok history всё ещё может поднимать старый факт, disabled flags самих jobs и sampledAt age не применяются; resource unknown и traffic отсутствующее значение требуют отдельного исправления; probe identity/enabled/individual interval остаются в170. Duplicate row guard не заменяет latest-current/error barrier. SQL/live fixtures и source/runtime parity для169 отсутствуют; удалённый backend/frontend не развёртывались, RouterOS writes/messages0, новые сетевые объекты не создавались. Доступность серверов в этом локальном блоке не перепроверялась. Runtime остаётся ux-fresh167/ux-bfd158; изменений169 в браузере пока нет.
Протокол: [локальная проверка169](ux-ui/acceptance/alert-server-identity-2026-10-12.json). Следующий170 — source enabled/freshness/current known guards, per-probe timing и identity с owned SQL positive/negative fixtures, затем coherent backend build/deploy/live и локальный коммит без push. Этап12.5 остаётся открытым.
## 12.5.170 — достоверность серверных, пробных и трафиковых сигналов (2026-10-12)
Завершён оставшийся объём169 в одном coherent backend блоке. Новый collector-source-signals отделяет чтение от чистого построения фактов. Builder читает текущие настройки и identity без seeding getters и без credentials: resourcesEnabled/pingEnabled имеют тот же fallback на legacy enabled, что scheduler; REST/traffic используют собственные enabled. Выбираются последние завершённые ok/error runs, включая NULL/error JSON, вместо only-ok history. Текущие server/traffic keys берутся только из newest run: empty/error/malformed/unknown не восстанавливают старую цель. Частичная ошибочная коллекция сохраняет известные строки других устройств. ServerID/name/host/enabled/duplicate guards169 сохранены; пустое имя может использовать host, как collector. producer sample и completion проверяются по3 effective periods, future tolerance1 period; минимумы5s resources/traffic и10s REST повторяют scheduler. Resource неизвестное значение не становится offline; Traffic требует ok=true и конечные неотрицательные RX/TX, отсутствующее/ошибка не становится0. Подтверждённый нулевой трафик остаётся0.
Ping сопоставляется с текущим probeId/name/target/sourceServerId/interface/source label, enabled probe и enabled source. Повторные цели/строки исключаются; более новое unknown/null/fatal/error observation не подменяется старым. Valid due-only batch может не содержать медленную пробу: её latest observed row сохраняется в пределах своего TTL. Effective probe period учитывает individual interval и глобальный tick, default минимум5s; future tolerance привязана к глобальному tick. Чтение охватывает3 максимальных настроенных probe periods, с защитным пределом4096 rows (при очень большом масштабе возможен консервативный пропуск, масштаб этим блоком не принят). Сэмплы до server/probe updatedAt не подтверждают новую конфигурацию. Server timelines прекращают recovery history на unknown/invalid/ambiguous barrier; свежие resource/REST подтверждения объединяются без выдуманного cross-source recovery. Выбирается реальное собственное history успешного источника.
Постоянные чистые тесты проверяют config/identity/time/empty/error/partial/unknown/numeric boundaries, медленные due probes, изменения конфигурации, recovery и настоящий evaluateRule путь с положительным и отрицательным control (server offline, loss и RX low). Локальные alert-engine и scheduler profiles прошли, backend build и ESLint прошли. Локальный PostgreSQL недоступен, SQL branches там skipped; положительная SQL-приёмка выполнена отдельно на Node22/PostgreSQL18.6: test:alert-engine за18622ms,16 новых SQL cases (включая31 Ping batches, больше прежнего лимита24),8 GRE/BGP SQL rows и actual confirm-stability passed без PG skips.
Автоматическая проверка отклонила копирование всех production env в persistent файл; этот способ не использован. Для SQL созданы собственная база mm_accept_166_1791791913287 и отдельная LOGIN роль без SUPERUSER/CREATEDB/CREATEROLE/BYPASSRLS, пароль сгенерирован временно и не публиковался. URL/роль ограничены own DB; main credentials/JWT не копировались. Тестовые строки/контейнер, база и роль удалены и отсутствие проверено. Main SQL fixtures/RouterOS/messages отсутствуют; обычные фоновые сборщики main не останавливались. Полная24-profile/UI1260/context221 regression157/166 без новой причины не повторялась.
Production backend собран инкрементально на прежней базе ux-fresh167 с прежними зависимостями: backend dependency hash совпал.433 source inputs и1008 emitted files совпали поSHA256 с LF normalization, запущенный imageId совпал с проверенным образом. Развёрнут ux-sources170/version1.0.0-test.sources170, backend/PG healthy; frontend ux-bfd158 прежний. В.env изменён только BACKEND_IMAGE_TAG, для rollback сохранён прежний tag ux-fresh167, копия всего secret env не создавалась. После cleanup только3 основных running containers.
Readonly production PG builder подтвердил1 группу одинаковых enabled labels и0 server/traffic/probe signals. Ноль проб соответствует отсутствию configured probes, server/traffic подавлены из-за неоднозначности двух CHR с одним именем; это не отказ устройств и не доказательство положительных измерений (они проверены в owned SQL). Имена/пароли не менялись.4 read-only API checks включают оба selected CHR BGP endpoints и catalog без failures;6 UI cases /alerts,/servers,/uptime при1440/320 passed, pageerrors/API writes/overflow0. Первые локальные network calls получили sandbox EACCES, повтор с разрешённым network доступом passed; UI helper сначала ожидал отсутствующий в существующей странице h1, исправлен на фактический main без изменения frontend. Это scoped smoke, не новая полная accessibility сертификация.
Протоколы: [SQL16/cleanup](ux-ui/acceptance/collector-sources-sql-2026-10-12.json), [source433](ux-ui/acceptance/collector-sources-source-2026-10-12.json), [compiled1008](ux-ui/acceptance/collector-sources-compiled-2026-10-12.json), [readonly production](ux-ui/acceptance/collector-sources-production-2026-10-12.json), [runtime](ux-ui/acceptance/collector-sources-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/collector-sources-deployed-2026-10-12.json).
Следующий171 — понятная диагностика неоднозначных/отключённых целей оповещений: silent suppression безопасен для engine, но пользователь должен понимать причину отсутствия события. Миграция name-based rules на stable ID, scale/multi-instance/physical WAN/внешние Telegram/Portal/client OS остаются отдельными условиями12.5. Локальный коммит без push.
+20 -2
View File
@@ -1,6 +1,6 @@
# Тестовый сервер UX/UI — 2026-10-10
Сервер161.104.46.222: завершён локальный блок 12.5.169 — identity/config guards серверных и traffic signals. Проверяются текущие id/name/host/enabled; неоднозначные имена enabled устройств исключаются. Пробы, enabled источников, свежесть и latest-error barriers ещё не исправлены. Profile/build/ESLint passed; SQL skipped, deployment отсутствует. Стенд остаётся ux-fresh167/ux-bfd158. Следующий170 — завершить source freshness/current/per-probe guards, owned PG приёмку и общий backend deploy;12.5 открыт.
Сервер161.104.46.222: завершён12.5.170 — source enabled/current/unknown/freshness/per-probe guards, включая169. Own PG16 source cases +GRE/BGP8/stability passed; temporary DB/role/container removed. Source433/compiled1008 parity; backend ux-sources170 healthy, frontend ux-bfd158 прежний;6 real UI/4 API reads passed, оба CHR доступны. Одноимённые server/traffic targets подавлены, имена/пароли не менялись. Следующий171 — показать неоднозначные/отключённые цели оповещений в UI и объяснить отсутствие подтверждённого сигнала;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-fresh167 и mmapp-test/frontend:ux-bfd158; запущены |
| Образы приложения | mmapp-test/backend:ux-sources170 и mmapp-test/frontend:ux-bfd158; запущены |
Compose SHA256:3052bd6ab5d9865f97605cebfb5ff27a4410cd3357b81153a65a87fb7c92582f. OptionalBACKEND_IMAGE_TAG позволяетобновлятьbackendотдельно. Локальный и удалённый файлы совпали. Шаблоны: [Compose](../deploy/docker-compose.test.yml), [env без секретов](../deploy/env.test.example).
@@ -1621,3 +1621,21 @@ flowchart LR
Ограничения сохранены явно: only-ok history всё ещё может поднимать старый факт, disabled flags самих jobs и sampledAt age не применяются; resource unknown и traffic отсутствующее значение требуют отдельного исправления; probe identity/enabled/individual interval остаются в170. Duplicate row guard не заменяет latest-current/error barrier. SQL/live fixtures и source/runtime parity для169 отсутствуют; удалённый backend/frontend не развёртывались, RouterOS writes/messages0, новые сетевые объекты не создавались. Доступность серверов в этом локальном блоке не перепроверялась. Runtime остаётся ux-fresh167/ux-bfd158; изменений169 в браузере пока нет.
Протокол: [локальная проверка169](ux-ui/acceptance/alert-server-identity-2026-10-12.json). Следующий170 — source enabled/freshness/current known guards, per-probe timing и identity с owned SQL positive/negative fixtures, затем coherent backend build/deploy/live и локальный коммит без push. Этап12.5 остаётся открытым.
## 12.5.170 — достоверность серверных, пробных и трафиковых сигналов (2026-10-12)
Завершён оставшийся объём169 в одном coherent backend блоке. Новый collector-source-signals отделяет чтение от чистого построения фактов. Builder читает текущие настройки и identity без seeding getters и без credentials: resourcesEnabled/pingEnabled имеют тот же fallback на legacy enabled, что scheduler; REST/traffic используют собственные enabled. Выбираются последние завершённые ok/error runs, включая NULL/error JSON, вместо only-ok history. Текущие server/traffic keys берутся только из newest run: empty/error/malformed/unknown не восстанавливают старую цель. Частичная ошибочная коллекция сохраняет известные строки других устройств. ServerID/name/host/enabled/duplicate guards169 сохранены; пустое имя может использовать host, как collector. producer sample и completion проверяются по3 effective periods, future tolerance1 period; минимумы5s resources/traffic и10s REST повторяют scheduler. Resource неизвестное значение не становится offline; Traffic требует ok=true и конечные неотрицательные RX/TX, отсутствующее/ошибка не становится0. Подтверждённый нулевой трафик остаётся0.
Ping сопоставляется с текущим probeId/name/target/sourceServerId/interface/source label, enabled probe и enabled source. Повторные цели/строки исключаются; более новое unknown/null/fatal/error observation не подменяется старым. Valid due-only batch может не содержать медленную пробу: её latest observed row сохраняется в пределах своего TTL. Effective probe period учитывает individual interval и глобальный tick, default минимум5s; future tolerance привязана к глобальному tick. Чтение охватывает3 максимальных настроенных probe periods, с защитным пределом4096 rows (при очень большом масштабе возможен консервативный пропуск, масштаб этим блоком не принят). Сэмплы до server/probe updatedAt не подтверждают новую конфигурацию. Server timelines прекращают recovery history на unknown/invalid/ambiguous barrier; свежие resource/REST подтверждения объединяются без выдуманного cross-source recovery. Выбирается реальное собственное history успешного источника.
Постоянные чистые тесты проверяют config/identity/time/empty/error/partial/unknown/numeric boundaries, медленные due probes, изменения конфигурации, recovery и настоящий evaluateRule путь с положительным и отрицательным control (server offline, loss и RX low). Локальные alert-engine и scheduler profiles прошли, backend build и ESLint прошли. Локальный PostgreSQL недоступен, SQL branches там skipped; положительная SQL-приёмка выполнена отдельно на Node22/PostgreSQL18.6: test:alert-engine за18622ms,16 новых SQL cases (включая31 Ping batches, больше прежнего лимита24),8 GRE/BGP SQL rows и actual confirm-stability passed без PG skips.
Автоматическая проверка отклонила копирование всех production env в persistent файл; этот способ не использован. Для SQL созданы собственная база mm_accept_166_1791791913287 и отдельная LOGIN роль без SUPERUSER/CREATEDB/CREATEROLE/BYPASSRLS, пароль сгенерирован временно и не публиковался. URL/роль ограничены own DB; main credentials/JWT не копировались. Тестовые строки/контейнер, база и роль удалены и отсутствие проверено. Main SQL fixtures/RouterOS/messages отсутствуют; обычные фоновые сборщики main не останавливались. Полная24-profile/UI1260/context221 regression157/166 без новой причины не повторялась.
Production backend собран инкрементально на прежней базе ux-fresh167 с прежними зависимостями: backend dependency hash совпал.433 source inputs и1008 emitted files совпали поSHA256 с LF normalization, запущенный imageId совпал с проверенным образом. Развёрнут ux-sources170/version1.0.0-test.sources170, backend/PG healthy; frontend ux-bfd158 прежний. В.env изменён только BACKEND_IMAGE_TAG, для rollback сохранён прежний tag ux-fresh167, копия всего secret env не создавалась. После cleanup только3 основных running containers.
Readonly production PG builder подтвердил1 группу одинаковых enabled labels и0 server/traffic/probe signals. Ноль проб соответствует отсутствию configured probes, server/traffic подавлены из-за неоднозначности двух CHR с одним именем; это не отказ устройств и не доказательство положительных измерений (они проверены в owned SQL). Имена/пароли не менялись.4 read-only API checks включают оба selected CHR BGP endpoints и catalog без failures;6 UI cases /alerts,/servers,/uptime при1440/320 passed, pageerrors/API writes/overflow0. Первые локальные network calls получили sandbox EACCES, повтор с разрешённым network доступом passed; UI helper сначала ожидал отсутствующий в существующей странице h1, исправлен на фактический main без изменения frontend. Это scoped smoke, не новая полная accessibility сертификация.
Протоколы: [SQL16/cleanup](ux-ui/acceptance/collector-sources-sql-2026-10-12.json), [source433](ux-ui/acceptance/collector-sources-source-2026-10-12.json), [compiled1008](ux-ui/acceptance/collector-sources-compiled-2026-10-12.json), [readonly production](ux-ui/acceptance/collector-sources-production-2026-10-12.json), [runtime](ux-ui/acceptance/collector-sources-runtime-2026-10-12.json), [API/UI](ux-ui/acceptance/collector-sources-deployed-2026-10-12.json).
Следующий171 — понятная диагностика неоднозначных/отключённых целей оповещений: silent suppression безопасен для engine, но пользователь должен понимать причину отсутствия события. Миграция name-based rules на stable ID, scale/multi-instance/physical WAN/внешние Telegram/Portal/client OS остаются отдельными условиями12.5. Локальный коммит без push.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,87 @@
{
"date": "2026-10-12",
"block": "12.5.170",
"runtime": {
"backend": "ux-sources170",
"frontend": "ux-bfd158"
},
"checks": [
{
"path": "/health",
"status": 200
},
{
"path": "/api/bgp/catalog",
"status": 200
},
{
"path": "/api/servers/1/bgp/sessions",
"status": 200
},
{
"path": "/api/servers/2/bgp/sessions",
"status": 200
}
],
"cases": [
{
"path": "/alerts",
"width": 1440,
"http200": true,
"primaryApi200": true,
"noOverflow": true,
"pageErrors": 0,
"apiWrites": 0
},
{
"path": "/alerts",
"width": 320,
"http200": true,
"primaryApi200": true,
"noOverflow": true,
"pageErrors": 0,
"apiWrites": 0
},
{
"path": "/servers",
"width": 1440,
"http200": true,
"primaryApi200": true,
"noOverflow": true,
"pageErrors": 0,
"apiWrites": 0
},
{
"path": "/servers",
"width": 320,
"http200": true,
"primaryApi200": true,
"noOverflow": true,
"pageErrors": 0,
"apiWrites": 0
},
{
"path": "/uptime",
"width": 1440,
"http200": true,
"primaryApi200": true,
"noOverflow": true,
"pageErrors": 0,
"apiWrites": 0
},
{
"path": "/uptime",
"width": 320,
"http200": true,
"primaryApi200": true,
"noOverflow": true,
"pageErrors": 0,
"apiWrites": 0
}
],
"bounds": [
"Read-only live API/browser; two CHR selected reads and catalog successes confirm access, no native mutations/password changes/messages",
"Both enabled CHR currently share the same display name; conservative server/traffic alert suppression is tested independently in read-only production PG",
"Positive and failed collector scenarios proved separately in owned PostgreSQL, not by empty production signal arrays"
]
}
@@ -0,0 +1,13 @@
{
"date": "2026-10-12",
"block": "12.5.170",
"version": "1.0.0-test.sources170",
"readOnlyConnection": true,
"duplicateEnabledLabelGroups": 1,
"serverSignals": 0,
"trafficSignals": 0,
"probeSignals": 0,
"positiveMeasurementsProvenInOwnedSql": true,
"routerOsWrites": 0,
"messages": 0
}
@@ -0,0 +1,16 @@
{
"date": "2026-10-12",
"block": "12.5.170",
"runtimeImage": "mmapp-test/backend:ux-sources170",
"runningImageId": "sha256:36949cdb7c9a1c19115022e21a22b95316fc1c460a14dc3b42a81a62f23c9a21",
"matchesVerifiedImage": true,
"health": "healthy",
"frontendUnchanged": "ux-bfd158",
"rollbackBackendTag": "ux-fresh167",
"rollbackFullEnvCopy": false,
"remainingRunningContainers": [
"mmapp-test-backend",
"mmapp-test-frontend",
"mmapp-test-postgres"
]
}
@@ -0,0 +1,10 @@
{
"date": "2026-10-12",
"block": "12.5.170",
"sourceMatched": 433,
"dependencyHashUnchanged": true,
"sourceBase": "8ffb8b5f00942270bfe954ee27ed5b2191955a34",
"normalization": "CRLF to LF",
"different": [],
"missing": []
}
@@ -0,0 +1,22 @@
{
"date": "2026-10-12",
"block": "12.5.170",
"database": "mm_accept_166_1791791913287",
"scope": "ephemeral role and precreated owned database; no copied production credentials",
"tests": [
{
"command": "npm --prefix backend run test:alert-engine",
"passed": true,
"durationMs": 18622,
"ownedHistorySqlRows": 8,
"ownedSourceSqlCases": 16,
"sqlConfirmStabilityPassed": true,
"postgresUnavailableSkip": false
}
],
"scopedRoleVerified": true,
"passed": true,
"databaseRemoved": true,
"roleRemoved": true,
"testContainerRemoved": true
}