Files
MikrotikManager/backend/src/services/alert-engine/decision-engine.ts
T
DenozordecandCursor ec43591a99
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
feat(db): перевести хранилище с SQLite на PostgreSQL
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 01:36:48 +07:00

137 lines
5.0 KiB
TypeScript

import type { ApiAlertGroup, ApiAlertRule } from "../alerts-service.js"
import { computeStabilityReadyMap } from "./confirm-stability.js"
import { isCooldownElapsed } from "./cooldown.js"
import { groupShouldFire } from "./groups.js"
import { isPositiveRecoveryTelegramText } from "./telegram-emoji.js"
import { shouldAllowRecoveryByPolicy } from "./state-machine.js"
import type { AlertEngineRuleDiag, RuleEvalHit } from "./types.js"
export type EngineStateMap = Map<string, { lastFiredAt: string; lastPayloadHash: string | null }>
export type StandaloneDecision = {
kind: "rule"
rule: ApiAlertRule
hit: RuleEvalHit
}
export type GroupDecision = {
kind: "group"
group: ApiAlertGroup
members: ApiAlertRule[]
hits: RuleEvalHit[]
}
function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): string {
const suffix = transition ?? "neutral"
return `rule:${ruleId}:${suffix}`
}
function textAfterGroupAlertHeader(fullBody: string): string {
const segs = fullBody.split(/\n\n/)
if (segs.length >= 2 && /^Группа\s*«/i.test(segs[0] ?? "")) return segs.slice(1).join("\n\n").trim()
return fullBody
}
export function buildRuleDiagnostics(
rules: ApiAlertRule[],
hitByRule: Map<string, RuleEvalHit | null>,
stabilityReady: Map<string, boolean>,
state: EngineStateMap,
canSend: boolean,
): AlertEngineRuleDiag[] {
const out: AlertEngineRuleDiag[] = []
for (const rule of rules) {
if (!rule.enabled) continue
const hit = hitByRule.get(rule.id) ?? null
const inGroup = Boolean(rule.groupId)
const evalHit = Boolean(hit)
const stab = stabilityReady.get(rule.id) ?? false
const key = ruleScopeKey(rule.id, hit?.transition)
const prev = state.get(key)
const cooldownOk = isCooldownElapsed(prev?.lastFiredAt, rule.cooldown)
let blocked: AlertEngineRuleDiag["blocked"]
if (!evalHit) blocked = "no_hit"
else if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) blocked = "stability"
else if (inGroup) blocked = "in_group"
else if (!stab) blocked = "stability"
else if (!cooldownOk) blocked = "cooldown"
else if (!canSend) blocked = "no_telegram"
else if (hit && isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) blocked = "dedupe_positive"
else blocked = undefined
out.push({
ruleId: rule.id,
inGroup,
evalHit,
hitTransition: hit?.transition,
hitMessage: hit?.message,
stabilityOk: stab,
cooldownOk,
telegramOk: canSend,
blocked,
})
}
return out
}
export async function computeDecisions(args: {
rules: ApiAlertRule[]
groups: ApiAlertGroup[]
hitByRule: Map<string, RuleEvalHit | null>
state: EngineStateMap
canSend: boolean
}): Promise<{
stabilityReady: Map<string, boolean>
standalone: StandaloneDecision[]
grouped: GroupDecision[]
ruleDiag: AlertEngineRuleDiag[]
}> {
const { rules, groups, hitByRule, state, canSend } = args
const stabilityReady = await computeStabilityReadyMap(rules, hitByRule)
const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend)
const standalone: StandaloneDecision[] = []
const grouped: GroupDecision[] = []
for (const rule of rules) {
if (!rule.enabled || rule.groupId) continue
const hit = hitByRule.get(rule.id)
if (!hit) continue
if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) continue
if (!stabilityReady.get(rule.id)) continue
const key = ruleScopeKey(rule.id, hit.transition)
const prev = state.get(key)
if (!isCooldownElapsed(prev?.lastFiredAt, rule.cooldown)) continue
if (isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) continue
standalone.push({ kind: "rule", rule, hit })
}
for (const g of groups) {
if (!g.enabled) continue
const members = rules.filter((r) => r.groupId === g.id && r.enabled)
if (members.length === 0) continue
const firing = members.map((r) => {
const h = hitByRule.get(r.id)
if (!shouldAllowRecoveryByPolicy(h ?? null, r.recoveryMode)) return false
return Boolean(h) && Boolean(stabilityReady.get(r.id))
})
if (!groupShouldFire(g.combineMode, firing)) continue
const hits: RuleEvalHit[] = []
for (const member of members) {
const hit = hitByRule.get(member.id)
if (!hit) continue
if (!shouldAllowRecoveryByPolicy(hit, member.recoveryMode)) continue
hits.push(hit)
}
if (hits.length === 0) continue
const key = `group:${g.id}`
const prev = state.get(key)
const cd = g.cooldownOverride ?? "5м"
if (!isCooldownElapsed(prev?.lastFiredAt, cd)) continue
const body = `Группа «${g.name}» (${g.combineMode === "any" ? "ANY" : "ALL"})\n\n${hits.map((h) => h.message).join("\n")}`
const hash = hits.map((h) => h.payloadHash).sort().join("|")
if (isPositiveRecoveryTelegramText(textAfterGroupAlertHeader(body)) && prev?.lastPayloadHash === hash) continue
grouped.push({ kind: "group", group: g, members, hits })
}
return { stabilityReady, standalone, grouped, ruleDiag }
}