fix(alerts): исключить неоднозначные и отключённые устройства из сигналов

This commit is contained in:
Denozordec committed 2026-10-12 14:14:45 +07:00
1 parent 65d4a17882
commit 8ffb8b5f00
11 files changed
+164 -14

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
"test:alerts-telegram": "tsx src/services/alerts-outbox-retry.test.ts && tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && tsx ../packages/contracts/src/alerts-normalization.test.ts && tsx src/services/alerts-config-execution.test.ts && tsx src/services/alerts-telegram-settings.test.ts && tsx src/services/alerts-telegram-send.test.ts && tsx src/routes/alerts.test.ts && tsx src/plugins/auth.alerts.test.ts",
"test:alert-engine": "tsx src/services/alert-engine/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/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",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
@@ -0,0 +1,28 @@
import assert from "node:assert/strict"
import { createServerSignalIdentityGuard, type AlertServerIdentity } from "./server-signal-identity.js"
const server = (id: number, name = "CHR", enabled = true): AlertServerIdentity => ({ id, name, host: `host-${id}`, enabled })
const sample = (id: number, name = "CHR") => ({ serverId: id, name, host: `host-${id}`, status: "offline", sumRxMbps: 100 })
// A valid, uniquely named current device survives; disabled/deleted devices do not.
const guard = createServerSignalIdentityGuard([server(1), server(2, "disabled", false)])
assert.deepEqual(guard([sample(1), sample(2, "disabled"), sample(3, "deleted")]), [sample(1)])
// Case/whitespace aliases of two enabled devices cannot mix history or traffic.
for (const inventory of [[server(1), server(2, " chr ")], [server(2, " chr "), server(1)]]) {
assert.deepEqual(createServerSignalIdentityGuard(inventory)([sample(1), sample(2, " chr ")]), [])
}
// Disabling the duplicate makes the remaining active device unambiguous.
assert.deepEqual(createServerSignalIdentityGuard([server(1), server(2, " chr ", false)])([sample(1), sample(2, " chr ")]), [sample(1)])
// Rename, host change and id reuse must not reassign old observations by display name.
assert.deepEqual(guard([sample(3), { ...sample(1), host: "old-host" }, { ...sample(1), name: "old-name" }]), [])
assert.deepEqual(createServerSignalIdentityGuard([server(1, "renamed")])([sample(1)]), [])
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)]), [])
// Filtering is immutable, and checks every run against the same current inventory.
const rows = [sample(1), sample(2)]
const before = JSON.stringify(rows)
guard(rows)
assert.equal(JSON.stringify(rows), before)
console.log("server signal identity: current/disabled/deleted/ambiguous/renamed/host/duplicate/id/immutability passed")
@@ -0,0 +1,36 @@
export interface AlertServerIdentity {
id: number | string
name: string
host: string
enabled: boolean
}
interface SnapshotServerIdentity { serverId: number; name: string; host: string }
const nameKey = (name: string) => name.trim().toLowerCase()
/** 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)
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)
if (Number.isSafeInteger(id) && id > 0 && key && nameCounts.get(key) === 1) eligible.set(id, server)
}
return <T extends SnapshotServerIdentity>(rows: T[]): T[] => {
const counts = new Map<number, number>()
for (const row of rows) if (row) counts.set(row.serverId, (counts.get(row.serverId) ?? 0) + 1)
return rows.filter(row => {
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()
})
}
}
@@ -2,6 +2,7 @@ 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,
@@ -187,14 +188,17 @@ function collectTrafficSignalsFromRuns(runs: TrafficRunSnapshot[]): TrafficServe
/** Собирает сигналы 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)
const restByKey = collectServerSignalsFromRestRuns(restRuns)
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) {
@@ -216,7 +220,7 @@ export async function buildSignalSnapshotFromCollectors(): Promise<SignalSnapsho
sampledAt,
servers,
probes: collectProbeSignalsFromRuns(pingRuns),
trafficByServer: collectTrafficSignalsFromRuns(trafficRuns),
trafficByServer: collectTrafficSignalsFromRuns(trafficRuns.map(run => ({ ...run, servers: guardServerRows(run.servers ?? []) }))),
greTunnels: collectGreSignalsFromRuns(greBgpRuns),
bgpSessions: collectBgpSignalsFromRuns(greBgpRuns),
}