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
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump. Co-authored-by: Cursor <cursoragent@cursor.com>
137 lines
5.0 KiB
TypeScript
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 }
|
|
}
|