fix(alerts): объяснить отключённые и неоднозначные цели оповещений

This commit is contained in:
Denozordec committed 2026-10-12 15:47:16 +07:00
1 parent db7105119d
commit d3f9a120e6
23 files changed
+1429 -16

No files matched your search

@@ -3,6 +3,7 @@ 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 { getAlertsMeta } from "../alerts-service.js"
import type { ApiAlertRule } from "../alerts-service.js"
const database = process.env.MMAPP_OWN_SQL_DB
@@ -41,12 +42,14 @@ else {
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++
assert.deepEqual((await getAlertsMeta()).targetIssues, [])
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++
assert((await getAlertsMeta()).targetIssues?.every(issue => issue.reason === "disabled-source"))
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]])
@@ -54,6 +57,7 @@ else {
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++
assert.deepEqual((await getAlertsMeta()).targetIssues?.filter(issue => issue.reason === "ambiguous").map(issue => issue.kind), ["server", "traffic"])
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", [])]) {
@@ -0,0 +1,33 @@
import type { AlertTargetIssue } from "@mmapp/contracts/alerts"
import type { CollectorSourceConfig } from "./collector-source-signals.js"
/** Configuration diagnostics only. This does not claim fresh samples or Telegram delivery. */
export function collectAlertTargetIssues(config: CollectorSourceConfig): AlertTargetIssue[] {
const out: AlertTargetIssue[] = []
const servers = new Map<string, typeof config.servers>()
for (const server of config.servers) {
const key = (server.name || server.host).trim().toLowerCase()
if (key) servers.set(key, [...(servers.get(key) ?? []), server])
}
const serverOn = (config.uptime?.resourcesEnabled ?? config.uptime?.enabled) === true || config.rest?.enabled === true
for (const group of servers.values()) {
const active = group.filter(s => s.enabled === true), target = (group[0].name || group[0].host).trim()
for (const [kind, sourceOn] of [["server", serverOn], ["traffic", config.traffic?.enabled === true]] as const) {
const reason = !active.length ? "disabled-object" : active.length > 1 ? "ambiguous" : !sourceOn ? "disabled-source" : null
if (reason) out.push({ kind, target, reason })
}
}
const probes = new Map<string, typeof config.probes>()
for (const probe of config.probes) {
if (!probe.name.trim() || !probe.target.trim()) continue
const key = `${probe.name.trim()} → ${probe.target.trim()}`
probes.set(key, [...(probes.get(key) ?? []), probe])
}
for (const [target, group] of probes) {
const active = group.filter(p => p.enabled && config.servers.some(s => s.enabled === true && Number(s.id) === p.srcServerId))
const sourceOn = (config.uptime?.pingEnabled ?? config.uptime?.enabled) === true
const reason = !active.length ? "disabled-object" : active.length > 1 ? "ambiguous" : !sourceOn ? "disabled-source" : null
if (reason) out.push({ kind: "probe", target, reason })
}
return out
}
@@ -3,6 +3,8 @@ import assert from "node:assert/strict"
import {test} from "node:test"
import type {AlertsResponse} from "@mmapp/contracts/alerts"
import {readAlertsOverview,alertsOverviewTransaction,buildAlertsMeta} from "./alerts-overview.js"
import {collectAlertTargetIssues} from "./alert-engine/target-issues.js"
import type {CollectorSourceConfig} from "./alert-engine/collector-source-signals.js"
const empty:AlertsResponse={revision:alertsConfigRevision({groups:[],rules:[]}),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:buildAlertsMeta([],[])}
test("Alerts overview uses one context with repeatable read/read only and rejects partial snapshots",async()=>{
const context={snapshot:structuredClone(empty)},calls:string[]=[];let transactions=0
@@ -17,3 +19,21 @@ test("Alerts metadata exposes existing probe keys without synthesizing per-serve
const withoutProbes=buildAlertsMeta(servers,[]);assert.deepEqual(withoutProbes.probeTargets,[]);assert.deepEqual(withoutProbes.rttLossTargets,[]);assert.equal(withoutProbes.serversDetail[0]?.type,"")
const actual=buildAlertsMeta(servers,[{name:"probe-fixture",target:"1.1.1.1"}]);assert.deepEqual(actual.probeTargets,["probe-fixture → 1.1.1.1"]);assert.deepEqual(actual.rttLossTargets,actual.probeTargets);assert.equal(JSON.stringify(actual).includes("8.8.8.8"),false)
})
test("Target diagnostics explain ambiguous and disabled configuration without claiming live status",()=>{
const config:CollectorSourceConfig={servers:[{id:1,name:"CHR",host:"one",enabled:true},{id:2,name:" chr ",host:"two",enabled:true}],probes:[],uptime:{enabled:true,resourcesEnabled:true,pingEnabled:true,intervalSec:15,probeIntervalSec:15},rest:{enabled:false,intervalSec:120},traffic:{enabled:true,intervalSec:30}}
assert.deepEqual(collectAlertTargetIssues(config),[{kind:"server",target:"CHR",reason:"ambiguous"},{kind:"traffic",target:"CHR",reason:"ambiguous"}])
const unique={...config,servers:config.servers.map((s,index)=>({...s,name:"server"+index}))}
assert.deepEqual(collectAlertTargetIssues(unique),[])
assert.deepEqual(collectAlertTargetIssues({...config,servers:[config.servers[0],{...config.servers[1],enabled:false}]}),[])
const disabled=collectAlertTargetIssues({...unique,servers:unique.servers.map(s=>({...s,enabled:false}))});assert.equal(disabled.length,4);assert(disabled.every(i=>i.reason==="disabled-object"))
const sourceOff={...unique,uptime:{...unique.uptime!,resourcesEnabled:false},traffic:{...unique.traffic!,enabled:false}}
assert(collectAlertTargetIssues(sourceOff).every(i=>i.reason==="disabled-source"))
assert.deepEqual(collectAlertTargetIssues({...sourceOff,rest:{...sourceOff.rest!,enabled:true}}).map(i=>i.kind),["traffic","traffic"])
const probe={id:"probe",name:"ping",target:"target",srcServerId:1,srcInterface:"",enabled:true,intervalSec:15}
const withProbe={...unique,probes:[probe,{...probe,id:"second"}]}
assert.deepEqual(collectAlertTargetIssues(withProbe),[{kind:"probe",target:"ping → target",reason:"ambiguous"}])
assert.deepEqual(collectAlertTargetIssues({...withProbe,probes:[probe,{...probe,id:"second",enabled:false}]}),[])
assert.equal(collectAlertTargetIssues({...unique,probes:[probe],uptime:{...unique.uptime!,pingEnabled:false}})[0].reason,"disabled-source")
const current=buildAlertsMeta(unique.servers.map(s=>({...s,site:null,country:null,type:null})),[probe],unique)
assert.deepEqual(current.targetIssues,[]);assert.equal("password" in current,false)
})
+4 -2
View File
@@ -1,5 +1,7 @@
import {alertsConfigRevision} from "./alerts-revision.js"
import {alertsResponseSchema,type AlertsResponse} from "@mmapp/contracts/alerts"
import {collectAlertTargetIssues} from "./alert-engine/target-issues.js"
import type {CollectorSourceConfig} from "./alert-engine/collector-source-signals.js"
export const alertsOverviewTransaction={isolationLevel:"repeatable read",accessMode:"read only"} as const
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<AlertsResponse>,options:typeof alertsOverviewTransaction)=>Promise<AlertsResponse>;telegram:(context:C)=>Promise<unknown>;groups:(context:C)=>Promise<unknown>;rules:(context:C)=>Promise<unknown>;history:(context:C)=>Promise<unknown>;meta:(context:C)=>Promise<unknown>}
export async function readAlertsOverview<C>(deps:Dependencies<C>):Promise<AlertsResponse>{
@@ -14,8 +16,8 @@ export async function readAlertsOverview<C>(deps:Dependencies<C>):Promise<Alerts
},alertsOverviewTransaction)
}
export function buildAlertsMeta(servers:{name:string|null;host:string;site:string|null;country:string|null;type:string|null}[],probes:{name:string;target:string}[]){
export function buildAlertsMeta(servers:{name:string|null;host:string;site:string|null;country:string|null;type:string|null}[],probes:{name:string;target:string}[],config?:CollectorSourceConfig){
const labels=servers.map(server=>server.name?.trim()||server.host)
const probeTargets=probes.map(probe=>probe.name+" → "+probe.target)
return {servers:labels,greClients:labels,probeTargets,rttLossTargets:probeTargets,trafficServers:labels,serversDetail:servers.map(server=>({name:server.name?.trim()||"",host:server.host.trim(),site:server.site?.trim()||"",country:server.country?.trim()||"",type:server.type??""}))}
return {servers:labels,greClients:labels,probeTargets,rttLossTargets:probeTargets,trafficServers:labels,serversDetail:servers.map(server=>({name:server.name?.trim()||"",host:server.host.trim(),site:server.site?.trim()||"",country:server.country?.trim()||"",type:server.type??""})),...(config?{targetIssues:collectAlertTargetIssues(config)}:{})}
}
+11 -2
View File
@@ -15,6 +15,9 @@ import {
alertTelegramSettings,
servers,
uptimeProbes,
uptimeSettings,
serversApiPingSettings,
trafficSettings,
} from "../db/schema.js"
export type ApiAlertGroup = {
@@ -495,6 +498,8 @@ export async function listMergedHistory(persistLimit = 40, liveCap = 25,context:
export async function getAlertsMeta(context:AlertsReadContext=db) {
const srvRows = await context
.select({
id: servers.id,
enabled: servers.enabled,
name: servers.name,
host: servers.host,
site: servers.site,
@@ -502,8 +507,12 @@ export async function getAlertsMeta(context:AlertsReadContext=db) {
type: servers.type,
})
.from(servers)
const probes=await context.select({name:uptimeProbes.name,target:uptimeProbes.target}).from(uptimeProbes)
return buildAlertsMeta(srvRows,probes)
const probes=await context.select({id:uptimeProbes.id,name:uptimeProbes.name,target:uptimeProbes.target,enabled:uptimeProbes.enabled,
srcServerId:uptimeProbes.srcServerId,srcInterface:uptimeProbes.srcInterface,intervalSec:uptimeProbes.intervalSec}).from(uptimeProbes)
const uptime=(await context.select().from(uptimeSettings).where(eq(uptimeSettings.id,1)).limit(1))[0]
const rest=(await context.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id,1)).limit(1))[0]
const traffic=(await context.select().from(trafficSettings).where(eq(trafficSettings.id,1)).limit(1))[0]
return buildAlertsMeta(srvRows,probes,{servers:srvRows,probes,uptime,rest,traffic})
}
export async function getAlertsOverview(){