From 9bc3a5db77bbf7aa9763d1d019d26163d5854f02 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 17:22:41 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B2?= =?UTF-8?q?=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/plugins/auth.alerts.test.ts | 2 +- backend/src/routes/alerts.test.ts | 5 +- backend/src/routes/alerts.ts | 14 +- .../services/alerts-config-execution.test.ts | 11 + .../src/services/alerts-config-execution.ts | 20 ++ backend/src/services/alerts-service.ts | 193 +++++++++--------- docs/ux-ui-plan.md | 4 +- docs/ux-ui-progress.md | 10 +- docs/ux-ui-stage-11b.md | 2 + 10 files changed, 150 insertions(+), 113 deletions(-) create mode 100644 backend/src/services/alerts-config-execution.test.ts create mode 100644 backend/src/services/alerts-config-execution.ts diff --git a/backend/package.json b/backend/package.json index 8fb2642e..208ac5a3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/plugins/auth.alerts.test.ts b/backend/src/plugins/auth.alerts.test.ts index 0535a4bb..112b5ce9 100644 --- a/backend/src/plugins/auth.alerts.test.ts +++ b/backend/src/plugins/auth.alerts.test.ts @@ -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)} diff --git a/backend/src/routes/alerts.test.ts b/backend/src/routes/alerts.test.ts index 0044859e..e56b578f 100644 --- a/backend/src/routes/alerts.test.ts +++ b/backend/src/routes/alerts.test.ts @@ -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") diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index cc8c8225..61960e6c 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -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) => { diff --git a/backend/src/services/alerts-config-execution.test.ts b/backend/src/services/alerts-config-execution.test.ts new file mode 100644 index 00000000..a41b8e6d --- /dev/null +++ b/backend/src/services/alerts-config-execution.test.ts @@ -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)=>{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)=>{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")}) diff --git a/backend/src/services/alerts-config-execution.ts b/backend/src/services/alerts-config-execution.ts new file mode 100644 index 00000000..9fc1e293 --- /dev/null +++ b/backend/src/services/alerts-config-execution.ts @@ -0,0 +1,20 @@ +import {normalizeAlertsConfig,alertsRulesReceiptSchema,type PutRulesBody} from "@mmapp/contracts/alerts" +type Receipt=ReturnType +type Normalized=ReturnType&{groups:NonNullable["groups"]>} +interface Dependencies{transaction:(work:(context:C)=>Promise)=>Promise;lock:(context:C)=>Promise;groups:(context:C)=>Promise;write:(context:C,value:Normalized)=>Promise;read:(context:C)=>Promise} +export async function executeAlertsConfigReplacement(input:PutRulesBody,deps:Dependencies):Promise{ + 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,actual:Record|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 + }) +} diff --git a/backend/src/services/alerts-service.ts b/backend/src/services/alerts-service.ts index 616378b8..313083d8 100644 --- a/backend/src/services/alerts-service.ts +++ b/backend/src/services/alerts-service.ts @@ -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> { - 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 + +async function lastFiredByRuleId(context:AlertsReadContext=db): Promise> { + 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() 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> { - const rows = await db +async function targetsByRuleId(context:AlertsReadContext=db): Promise> { + const rows = await context .select() .from(alertRuleTargets) .orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id)) @@ -96,8 +94,8 @@ async function targetsByRuleId(): Promise> { return m } -async function conditionsByRuleId(): Promise> { - const rows = await db +async function conditionsByRuleId(context:AlertsReadContext=db): Promise> { + const rows = await context .select() .from(alertRuleConditions) .orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id)) @@ -111,8 +109,8 @@ async function conditionsByRuleId(): Promise> { return m } -export async function listAlertGroups(): Promise { - const rows = await db +export async function listAlertGroups(context:AlertsReadContext=db): Promise { + const rows = await context .select() .from(alertGroups) .orderBy(asc(alertGroups.name)) @@ -125,11 +123,11 @@ export async function listAlertGroups(): Promise { })) } -export async function listAlertRules(): Promise { - 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 { + 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[0]>[0] +export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) { + const now=sql`now()` + return executeAlertsConfigReplacement(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() { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index bbf0faca..d9f52ae7 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index d3ddea7f..e33000cf 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 1710acba..be72e30d 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -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.