fix(alerts): подтверждать конфигурацию внутри транзакции сохранения

This commit is contained in:
Denozordec committed 2026-10-03 17:22:41 +07:00
1 parent 6093442bcf
commit 9bc3a5db77
10 files changed
+150 -113

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @mmapp/contracts && tsc",
"test:alerts-telegram": "tsx ../packages/contracts/src/alerts-normalization.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:alerts-telegram": "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/alert-engine/confirm-stability.test.ts",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
+1 -1
View File
@@ -6,7 +6,7 @@ const {default:authPlugin,requireAuth}=await import("./auth.js")
const {createAlertsRoutes,defaultAlertsDependencies}=await import("../routes/alerts.js")
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
let writes=0,sends=0,reads=0
await app.register(createAlertsRoutes({...defaultAlertsDependencies,telegram:async()=>({chatId:"123",tokenConfigured:true,messageThreadId:null}),groups:async()=>[],rules:async()=>[],history:async()=>[],meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++}}),{prefix:"/api"})
await app.register(createAlertsRoutes({...defaultAlertsDependencies,telegram:async()=>({chatId:"123",tokenConfigured:true,messageThreadId:null}),groups:async()=>[],rules:async()=>[],history:async()=>[],meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++;return {ok:true as const,groups:[],rules:[]}}}),{prefix:"/api"})
for(const permission of ["mm:alerts:read","mm:alerts:write","mm:alerts:admin","mm:settings:admin"]){
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
for(const [method,url] of [["GET","/api/alerts"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)}
+3 -2
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict"
import Fastify from "fastify"
import {createAlertsRoutes,defaultAlertsDependencies} from "./alerts.js"
import {executeTelegramSettingsUpdate} from "../services/alerts-telegram-settings.js"
let replacements=0
let replacements=0,replaceConflict=false
let stored={botToken:"fixture-secret",chatId:"123",messageThreadId:10 as number|null},reads=0,writes=0,sends=0,failSend=false,failWrite=false
const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),telegram:async()=>({chatId:stored.chatId,tokenConfigured:!!stored.botToken,messageThreadId:stored.messageThreadId}),groups:async()=>[],rules:async()=>[],history:async()=>[],replace:async()=>{replacements++},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),telegram:async()=>({chatId:stored.chatId,tokenConfigured:!!stored.botToken,messageThreadId:stored.messageThreadId}),groups:async()=>[],rules:async()=>[],history:async()=>[],replace:async()=>{replacements++;if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,groups:[],rules:[]}},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
const test=()=>app.inject({method:"POST",url:"/api/alerts/telegram/test",payload:{}})
let response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,200);assert.equal(response.body.includes("fixture-secret"),false);assert.deepEqual(response.json().rules,[])
response=await test();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{ok:true});assert.equal(reads,1);assert.equal(sends,1)
@@ -16,4 +16,5 @@ failWrite=true;response=await app.inject({method:"PUT",url:"/api/alerts/telegram
stored={...stored,botToken:""};response=await test();assert.equal(response.statusCode,400);assert.equal(sends,2)
const invalidRule={id:"r",name:"Server",type:"server",target:"host",condition:"down",severity:"critical",enabled:false,cooldown:"5м",chatId:""}
for(const payload of [{rules:[invalidRule,invalidRule],groups:[]},{rules:[{...invalidRule,targets:[" "]}],groups:[]},{rules:[{...invalidRule,groupId:"missing"}],groups:[]}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload});assert.equal(response.statusCode,400)}assert.equal(replacements,0)
replaceConflict=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload:{rules:[],groups:[]}});assert.equal(response.statusCode,409);assert.equal(replacements,1);assert.equal(response.json().ok,undefined)
await app.close();console.log("Alerts HTTP: one stored test snapshot, strict numeric thread, selective save, unknown send/no retry, missing returning/no success, no token echo")
+3 -11
View File
@@ -43,15 +43,7 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
try { normalizeAlertsConfig(parsed.data) } catch { return reply.status(400).send({error:"Проверьте объекты, условия и группы правил"}) }
const groups =
parsed.data.groups ??
(await deps.groups()).map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride,
}))
const groups = parsed.data.groups
const rules = parsed.data.rules.map((r) => ({
id: r.id,
name: r.name,
@@ -74,8 +66,8 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
chatId: r.chatId,
}))
await deps.replace({ groups, rules })
return reply.send({ ok: true, groups: await deps.groups(), rules: await deps.rules() })
try {return reply.send(await deps.replace({ groups, rules }))}
catch(error){if(error instanceof Error&&error.message.includes("уже выполняется"))return reply.status(409).send({error:error.message});throw error}
})
app.put("/alerts/telegram", async (req, reply) => {
@@ -0,0 +1,11 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
const initial={groups:[{id:"g",name:"Group",combineMode:"all" as const,enabled:false,cooldownOverride:null}],rules:[]}
function fixture(){let state=structuredClone(initial),locks:boolean|undefined=true,failWrite=false,badRead=false,wrongRead=false;const calls:string[]=[];return {calls,state:()=>state,conflict:()=>locks=false,unknown:()=>locks=undefined,fail:()=>failWrite=true,bad:()=>badRead=true,wrong:()=>wrongRead=true,deps:{transaction:async(work:(context:typeof initial)=>Promise<unknown>)=>{calls.push("begin");const context=structuredClone(state);try{const receipt=await work(context);state=context;calls.push("commit");return receipt as never}catch(error){calls.push("rollback");throw error}},lock:async()=>{calls.push("lock");return locks},groups:async(context:typeof initial)=>{calls.push("groups");return context.groups},write:async(context:typeof initial,value:ReturnType<typeof normalizeAlertsConfig>)=>{calls.push("write");context.groups=value.groups!;if(failWrite)throw Error("insert failed")},read:async(context:typeof initial)=>{calls.push("read");return badRead?{ok:false}:wrongRead?{ok:true,...context,groups:initial.groups}:{ok:true,...context}}}}}
test("omitted groups are read and confirmed inside one locked transaction",async()=>{const f=fixture();const receipt=await executeAlertsConfigReplacement({rules:[]},f.deps);assert.equal(receipt.ok,true);assert.deepEqual(receipt.groups,initial.groups);assert.deepEqual(f.calls,["begin","lock","groups","write","read","commit"])})
test("write/readback failures roll back and never repeat",async()=>{for(const step of ["write","read"]){const f=fixture();if(step==="write")f.fail();else f.bad();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps));assert.deepEqual(f.state(),initial);assert.equal(f.calls.filter(s=>s==="write").length,1);assert.equal(f.calls.includes("commit"),false);assert.equal(f.calls.at(-1),"rollback")}})
test("false or unknown lock prevents every destructive write",async()=>{for(const unknown of [false,true]){const f=fixture();if(unknown)f.unknown();else f.conflict();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/выполняется|не подтверждена/);assert.equal(f.calls.includes("write"),false);assert.deepEqual(f.state(),initial)}})
test("literal success with another config rolls back instead of confirming unrelated data",async()=>{const f=fixture();f.wrong();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/не совпадают/);assert.deepEqual(f.state(),initial);assert.equal(f.calls.at(-1),"rollback")})
@@ -0,0 +1,20 @@
import {normalizeAlertsConfig,alertsRulesReceiptSchema,type PutRulesBody} from "@mmapp/contracts/alerts"
type Receipt=ReturnType<typeof alertsRulesReceiptSchema.parse>
type Normalized=ReturnType<typeof normalizeAlertsConfig>&{groups:NonNullable<ReturnType<typeof normalizeAlertsConfig>["groups"]>}
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<Receipt>)=>Promise<Receipt>;lock:(context:C)=>Promise<boolean|undefined>;groups:(context:C)=>Promise<Normalized["groups"]>;write:(context:C,value:Normalized)=>Promise<void>;read:(context:C)=>Promise<unknown>}
export async function executeAlertsConfigReplacement<C>(input:PutRulesBody,deps:Dependencies<C>):Promise<Receipt>{
normalizeAlertsConfig(input)
return deps.transaction(async context=>{
const locked=await deps.lock(context)
if(locked===false)throw Error("Сохранение правил уже выполняется")
if(locked!==true)throw Error("Блокировка правил не подтверждена")
const normalized=normalizeAlertsConfig({...input,groups:input.groups??await deps.groups(context)}) as Normalized
await deps.write(context,normalized)
const parsed=alertsRulesReceiptSchema.safeParse(await deps.read(context))
if(!parsed.success)throw Error("Сохранённая конфигурация оповещений не подтверждена")
const receipt=parsed.data
const match=(expected:Record<string,unknown>,actual:Record<string,unknown>|undefined)=>actual!==undefined&&Object.entries(expected).every(([key,value])=>JSON.stringify(actual[key])===JSON.stringify(value))
if(receipt.rules.length!==normalized.rules.length||receipt.groups.length!==normalized.groups.length||normalized.rules.some(rule=>!match(rule,receipt.rules.find(row=>row.id===rule.id)))||normalized.groups.some(group=>!match(group,receipt.groups.find(row=>row.id===group.id))))throw Error("Сохранённые правила или группы не совпадают с отправленными")
return receipt
})
}
+97 -96
View File
@@ -1,4 +1,4 @@
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
import {sendTelegramRequest} from "./alerts-telegram-send.js"
import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js"
import { asc, desc, eq, sql } from "drizzle-orm"
@@ -67,22 +67,20 @@ async function ensureTelegramRow() {
return row!
}
async function lastFiredByRuleId(): Promise<Map<string, string>> {
const rows = await dbAll<{ ruleId: string; mx: string }>(
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
type AlertsReadContext=Pick<typeof db,"select"|"execute">
async function lastFiredByRuleId(context:AlertsReadContext=db): Promise<Map<string, string>> {
const result = await context.execute(sql`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx FROM alert_history WHERE rule_id IS NOT NULL AND rule_id != '' GROUP BY rule_id`)
const rows = result.rows as {ruleId:string;mx:string|Date}[]
const m = new Map<string, string>()
for (const r of rows) {
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx instanceof Date?r.mx.toISOString():r.mx)
}
return m
}
async function targetsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
async function targetsByRuleId(context:AlertsReadContext=db): Promise<Map<string, string[]>> {
const rows = await context
.select()
.from(alertRuleTargets)
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
@@ -96,8 +94,8 @@ async function targetsByRuleId(): Promise<Map<string, string[]>> {
return m
}
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
async function conditionsByRuleId(context:AlertsReadContext=db): Promise<Map<string, string[]>> {
const rows = await context
.select()
.from(alertRuleConditions)
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
@@ -111,8 +109,8 @@ async function conditionsByRuleId(): Promise<Map<string, string[]>> {
return m
}
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
const rows = await db
export async function listAlertGroups(context:AlertsReadContext=db): Promise<ApiAlertGroup[]> {
const rows = await context
.select()
.from(alertGroups)
.orderBy(asc(alertGroups.name))
@@ -125,11 +123,11 @@ export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
}))
}
export async function listAlertRules(): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId()
const tgtMap = await targetsByRuleId()
const condMap = await conditionsByRuleId()
const rows = await db.select().from(alertRules)
export async function listAlertRules(context:AlertsReadContext=db): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId(context)
const tgtMap = await targetsByRuleId(context)
const condMap = await conditionsByRuleId(context)
const rows = await context.select().from(alertRules)
return rows.map((r) => {
const targets = tgtMap.get(r.id)
const ts = targets?.length ? targets : [r.target]
@@ -205,91 +203,94 @@ export type ReplaceAlertGroupInput = {
}
/** Атомарно заменяет группы, правила и строки targets. */
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`now()`
const normalized = normalizeAlertsConfig(payload)
const groups = normalized.groups!
const rules = normalized.rules
await db.transaction(async (tx) => {
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
})
}
for (const r of rules) {
const rawTargets =
r.targets && r.targets.length > 0
? r.targets.map((t) => t.trim()).filter(Boolean)
: [String(r.target ?? "").trim()].filter(Boolean)
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
const rawConds =
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: [String(r.condition ?? "").trim()].filter(Boolean)
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
const stab =
r.confirmStabilitySec != null &&
Number.isFinite(r.confirmStabilitySec) &&
r.confirmStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
: null
const recoveryMode =
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
const recoveryStabilitySec =
r.recoveryStabilitySec != null &&
Number.isFinite(r.recoveryStabilitySec) &&
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
target: summary || "—",
condition: condSummary,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: stab,
recoveryMode,
recoveryStabilitySec,
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
type AlertsWriteContext=Parameters<Parameters<typeof db.transaction>[0]>[0]
export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now=sql`now()`
return executeAlertsConfigReplacement<AlertsWriteContext>(payload, {
transaction:work=>db.transaction(work),
lock:async tx=>{const result=await tx.execute(sql`SELECT pg_try_advisory_xact_lock(8723103) AS acquired`);return typeof result.rows[0]?.acquired==="boolean"?result.rows[0].acquired:undefined},
groups:tx=>listAlertGroups(tx),
write:async(tx,{groups,rules})=>{
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
for (const r of rules) {
const rawTargets =
r.targets && r.targets.length > 0
? r.targets.map((t) => t.trim()).filter(Boolean)
: [String(r.target ?? "").trim()].filter(Boolean)
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
const rawConds =
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: [String(r.condition ?? "").trim()].filter(Boolean)
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
const stab =
r.confirmStabilitySec != null &&
Number.isFinite(r.confirmStabilitySec) &&
r.confirmStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
: null
const recoveryMode =
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
const recoveryStabilitySec =
r.recoveryStabilitySec != null &&
Number.isFinite(r.recoveryStabilitySec) &&
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
target: summary || "—",
condition: condSummary,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: stab,
recoveryMode,
recoveryStabilitySec,
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
})
}
}
}
},
read:async tx=>({ok:true,groups:await listAlertGroups(tx),rules:await listAlertRules(tx)}),
})
}
/** Совместимость: только правила, группы не трогаем. */
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = await listAlertGroups()
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
await replaceAlertsConfig({ rules })
}
export async function getTelegramSettingsRow() {
+3 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 11Б в работе; последний проверенный блок 11Б.4.2.2.1.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.2.2.1.2: transactional rules/groups receipt/readback, DI failure/rollback/concurrency; затем Alerts UI и outbox unknown audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Этап 11Б в работе; последний проверенный блок 11Б.4.2.2.1.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.1: Alerts UI source lifetime/strict readers/known/error/time; затем drafts/locks/rights/unknown/adaptive, GET consistency/outbox audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
@@ -312,3 +312,5 @@ REST запрос → ожидание → подтверждённый output /
2026-10-03: 11Б.4.2.1.2 — реальные Alerts HTTP/JWT gates и один Telegram stored snapshot, профильные tests/build/typecheck/lint прошли. Далее rules/groups 11Б.4.2.2.1.
2026-10-03: 11Б.4.2.2.1.1 — общая нормализация rules/groups и 400 до удаления; 8 unit и backend profile/builds/typecheck/lint прошли. Далее transaction receipt 11Б.4.2.2.1.2.
2026-10-03: 11Б.4.2.2.1.2 — transaction rules receipt/readback/lock, DI/API/JWT/build/typecheck/lint прошли. Реальный PG rollback/overlap и optimistic revision не приняты. Далее Alerts UI 11Б.4.3.1.
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.2.2.1.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.2.2.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.4.2.2.1.2 — transactional receipt правил (2026-10-03)
- Replace теперь возвращает strict rules/groups receipt из самой transaction. Omitted groups читает внутри той же transaction после nonblocking pg_try_advisory_xact_lock; lock false даёт 409 без очереди/удаления, unknown lock не подтверждает действие. Targets/conditions/groups/rules/last-fired readback использует tx context вместо отдельных pool запросов после commit.
- DI executor сверяет каждое переданное поле и набор ID с readback до возврата callback: write/read/мismatched receipt failure заставляет transaction отклонить callback, нет повторного replace. Router отправляет готовый receipt, не делает post-commit reread. Legacy replaceAlertRules передаёт omitted groups без предварительного GET.
- 4 transaction-control DI, 3 normalization, 7 Telegram service + HTTP/JWT (включая rules 409), backend build/typecheck/lint прошли. Это контроль работы callback, не реальное доказательство PG rollback/lock overlap; PostgreSQL не запускался.
- Полная replace по-прежнему допускает last-writer overwrite старым снимком после окончания другого save: optimistic revision/ETag пока нет. GET overview всё ещё multi-query, outbox unknown retry policy и UI прежние; Alerts целиком не принят.
- Следующий блок 11Б.4.3.1: UI source-keyed lifetime и strict независимые readers, known/error/time без demo fallback; затем drafts/selective Telegram, rules confirmation/locks/rights/unknown review и адаптивность. Отдельно согласованность GET и outbox unknown audit.
## Блок 11Б.4.2.2.1.1 — общая нормализация правил и групп (2026-10-03)
- normalizeAlertsConfig общий для client и backend до transaction/delete. Сохраняет порядок OR targets/conditions, legacy одиночные поля, прежние summary labels, cooldown/recovery/combine policy; 0 delay означает off/null. Known empty groups отличается от omitted groups. Дубликаты ID, неизвестная группа и whitespace-only объекты/условия отклоняются до записи; HTTP возвращает 400.
+2
View File
@@ -242,3 +242,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
Завершён 11Б.4.2.1.2: Alerts HTTP/JWT read/write gates и один Telegram snapshot; профильные tests/build/typecheck/lint прошли. Далее 11Б.4.2.2.1 rules/groups normalization/transactional receipt, затем UI.
Завершён 11Б.4.2.2.1.1: shared rules/groups normalization до delete, 8 unit/HTTP/JWT/builds/typecheck/lint прошли. Router readback остаётся после commit; omitted groups read вне transaction. Далее 11Б.4.2.2.1.2 transactional receipt/context/DI/concurrency; затем UI/outbox.
Завершён 11Б.4.2.2.1.2: transactional rules/groups receipt/readback и nonblocking replace lock/409; 4 DI + profile/build/typecheck/lint прошли. Real PG и optimistic revision не проверены. Далее 11Б.4.3.1 — Alerts UI source/strict readers/known/error/time; затем drafts/locks/rights/adaptive и GET/outbox audit.