fix(alerts): учитывать свежесть и настройки источников оповещений
This commit is contained in:
1 parent
8ffb8b5f00
commit
db7105119d
19 files changed
+1614
-233
No files matched your search
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user