From 6093442bcf78a6915529c60a9b3ad92ab7367a6d Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 17:16:21 +0700 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/src/routes/alerts.test.ts | 5 +++- backend/src/routes/alerts.ts | 2 ++ backend/src/services/alerts-service.ts | 5 +++- docs/ux-ui-plan.md | 4 +++- docs/ux-ui-progress.md | 9 +++++++- docs/ux-ui-stage-11b.md | 2 ++ .../src/alerts-normalization.test.ts | 12 ++++++++++ packages/contracts/src/alerts.ts | 23 +++++++++++++++++++ shared/api/alerts.ts | 7 ++---- 10 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 packages/contracts/src/alerts-normalization.test.ts diff --git a/backend/package.json b/backend/package.json index fe33c409..8fb2642e 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 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-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/routes/alerts.test.ts b/backend/src/routes/alerts.test.ts index 2e88c8ed..0044859e 100644 --- a/backend/src/routes/alerts.test.ts +++ b/backend/src/routes/alerts.test.ts @@ -2,8 +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 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()=>{},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++},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) @@ -13,4 +14,6 @@ for(const payload of [{},{token:4},{messageThreadId:1.5},{messageThreadId:214748 response=await app.inject({method:"PUT",url:"/api/alerts/telegram",payload:{chatId:" new "}});assert.equal(response.statusCode,200);assert.equal(response.json().telegram.chatId,"new");assert.equal(stored.botToken,"fixture-secret");assert.equal(stored.messageThreadId,10);assert.equal(response.body.includes("fixture-secret"),false);assert.equal(writes,1) failWrite=true;response=await app.inject({method:"PUT",url:"/api/alerts/telegram",payload:{chatId:"other"}});assert.equal(response.statusCode,500);assert.equal(response.json().ok,undefined);assert.equal(writes,2) 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) 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 7dd1ce0a..cc8c8225 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -13,6 +13,7 @@ import { } from "../services/alerts-service.js" import { putRulesSchema, + normalizeAlertsConfig, putTelegramSchema, testTelegramSchema, telegramSettingsReceiptSchema, @@ -41,6 +42,7 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret if (!parsed.success) { 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) => ({ diff --git a/backend/src/services/alerts-service.ts b/backend/src/services/alerts-service.ts index 2c246f96..616378b8 100644 --- a/backend/src/services/alerts-service.ts +++ b/backend/src/services/alerts-service.ts @@ -1,3 +1,4 @@ +import {normalizeAlertsConfig} from "@mmapp/contracts/alerts" import {sendTelegramRequest} from "./alerts-telegram-send.js" import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js" import { asc, desc, eq, sql } from "drizzle-orm" @@ -206,7 +207,9 @@ export type ReplaceAlertGroupInput = { /** Атомарно заменяет группы, правила и строки targets. */ export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) { const now = sql`now()` - const { groups, rules } = payload + 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) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index c6a4bdf1..bbf0faca 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.2.1.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.2.2.1: rules/groups normalization и transactional receipt/DI без readback после commit; затем Alerts UI. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 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. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). @@ -310,3 +310,5 @@ REST запрос → ожидание → подтверждённый output / 2026-10-03: 11Б.4.2.1.1 — selective Telegram UPDATE RETURNING, strict send receipt; 7 DI/send tests/build/lint прошли. confirm-stability пропущен без PG. Далее HTTP/JWT 11Б.4.2.1.2. 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. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 85cccc31..d3ddea7f 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.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.2.2.1.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 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. +- 3 normalization + 5 API unit групп прошли; backend service/HTTP/JWT profile, contracts/backend builds/typecheck/lint прошли. Normalization test включён в test:alerts-telegram. Реальные PG/Telegram не вызывались. +- Это подготовка transactional receipt, а не его приёмка: router всё ещё читает groups/rules отдельно после commit; при omitted groups предварительное чтение также вне transaction. Нет доказательства optimistic concurrency/защиты от overwrite несколькими клиентами. UI прежний. +- Следующий точный шаг 11Б.4.2.2.1.2: transaction возвращает подтверждённый снимок rules/groups; readback/last-fired внутри одного context, failure/rollback/no retry DI, сериализация replace. Затем Alerts UI, outbox unknown policy audit и остальные части 11Б/12. + ## Блок 11Б.4.2.1.2 — реальные Alerts HTTP/JWT и источник теста Telegram (2026-10-03) - Router вынесен в DI factory, production зависимости сохранены. Test Telegram читает один stored row token/chat/thread и передаёт resolved значения напрямую send helper; повторных DB rereads/fallback при отправке нет. Числовой thread строго int4, boolean/string/дробь/overflow отклоняются до reader/send. PUT Telegram проверяет public receipt перед ok. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 349733b7..1710acba 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -240,3 +240,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.4.2.1.1: selective Telegram UPDATE RETURNING/partial concurrent patches, singleton conflict-safe и strict bounded send receipt; 7 tests/build/lint прошли. Groups/evaluate regression прошла, confirm-stability skip без PG. Далее 11Б.4.2.1.2 — Alerts real HTTP/JWT/save/test и согласованный источник; UI прежний. Завершён 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. diff --git a/packages/contracts/src/alerts-normalization.test.ts b/packages/contracts/src/alerts-normalization.test.ts new file mode 100644 index 00000000..ef6a99c6 --- /dev/null +++ b/packages/contracts/src/alerts-normalization.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {normalizeAlertsConfig,type PutRulesBody} from "./alerts.js" +const rule={id:"r",name:"Server",type:"server" as const,target:"legacy",condition:"down",severity:"critical" as const,enabled:false,cooldown:"5м" as const,chatId:""} +test("Alerts normalization preserves policy and produces shared summaries/ordered OR arrays",()=>{ + const result=normalizeAlertsConfig({rules:[{...rule,targets:[" a ","b","c"],conditions:[" x ","y","z","q"],confirmStabilitySec:0,recoveryMode:"never",recoveryStabilitySec:0}],groups:[]}) + assert.deepEqual(result.rules[0].targets,["a","b","c"]);assert.deepEqual(result.rules[0].conditions,["x","y","z","q"]);assert.equal(result.rules[0].target,"a, b +1");assert.equal(result.rules[0].condition,"x · y · +2");assert.equal(result.rules[0].enabled,false);assert.equal(result.rules[0].recoveryMode,"never");assert.equal(result.rules[0].confirmStabilitySec,null);assert.equal(result.rules[0].recoveryStabilitySec,null);assert.equal(result.rules[0].cooldown,"5м") +}) +test("known empty differs from omitted groups; legacy target/condition retained",()=>{assert.deepEqual(normalizeAlertsConfig({rules:[],groups:[]}),{rules:[],groups:[]});const result=normalizeAlertsConfig({rules:[{...rule,groupId:"g"}]});assert.equal(result.groups,undefined);assert.deepEqual(result.rules[0].targets,["legacy"]);assert.deepEqual(result.rules[0].conditions,["down"]);assert.equal(result.rules[0].groupId,"g")}) +test("duplicates, dangling groups, whitespace-only objects/conditions and invalid delays reject",()=>{ + for(const payload of [{rules:[rule,rule]},{rules:[{...rule,groupId:"missing"}],groups:[]},{rules:[{...rule,targets:[" "]}]},{rules:[{...rule,conditions:[" "]}]},{rules:[{...rule,confirmStabilitySec:1.5}]},{rules:[],groups:[{id:"g",name:"Group",combineMode:"any",enabled:false,cooldownOverride:null},{id:"g",name:"Group",combineMode:"any",enabled:false,cooldownOverride:null}]}])assert.throws(()=>normalizeAlertsConfig(payload as PutRulesBody)) +}) diff --git a/packages/contracts/src/alerts.ts b/packages/contracts/src/alerts.ts index 557a848d..3891015a 100644 --- a/packages/contracts/src/alerts.ts +++ b/packages/contracts/src/alerts.ts @@ -107,3 +107,26 @@ export const alertsRulesReceiptSchema = z.object({ok:z.literal(true),...alertsCo export const telegramSettingsReceiptSchema = z.object({ok:z.literal(true),telegram:telegramPublicSchema}).strict() export const telegramTestReceiptSchema = z.object({ok:z.literal(true)}).strict() export type AlertsResponse = z.output + +/** Normalize the stored rule representation without altering OR/AND or cooldown policies. */ +export function normalizeAlertsConfig(input:PutRulesBody){ + const parsed=putRulesSchema.strict().safeParse(input) + if(!parsed.success)throw Error("Проверьте правила и группы оповещений") + const groups=parsed.data.groups?.map(group=>({...group,name:group.name.trim()||group.id})) + const groupIds=groups===undefined?undefined:new Set(groups.map(group=>group.id)) + const ids=new Set() + if(groups!==undefined&&groupIds!.size!==groups.length)throw Error("Повторяющиеся ID групп") + const rules=parsed.data.rules.map(rule=>{ + if(ids.has(rule.id))throw Error("Повторяющиеся ID правил") + ids.add(rule.id) + const targets=(rule.targets?.length?rule.targets:[rule.target??""]).map(value=>value.trim()).filter(Boolean) + const conditions=(rule.conditions?.length?rule.conditions:[rule.condition??""]).map(value=>value.trim()).filter(Boolean) + if(!targets.length||!conditions.length)throw Error("Нужны непустые объекты и условия правила") + const groupId=rule.groupId?.trim()||null + if(groupId!==null&&groupIds!==undefined&&!groupIds.has(groupId))throw Error("Неизвестная группа правила") + const target=targets.length<=2?targets.join(", "):targets[0]+", "+targets[1]+" +"+(targets.length-2) + const condition=conditions.length===1?conditions[0]!:conditions.length<=3?conditions.join(" · "):conditions[0]+" · "+conditions[1]+" · +"+(conditions.length-2) + return {...rule,target,targets,condition,conditions,groupId,confirmStabilitySec:rule.confirmStabilitySec||null,recoveryMode:rule.recoveryMode??"always",recoveryStabilitySec:rule.recoveryStabilitySec||null} + }) + return {...(groups!==undefined?{groups}:{}),rules} +} diff --git a/shared/api/alerts.ts b/shared/api/alerts.ts index 69518c29..449f2172 100644 --- a/shared/api/alerts.ts +++ b/shared/api/alerts.ts @@ -1,15 +1,12 @@ import {z} from "zod" -import {alertsResponseSchema,alertsRulesReceiptSchema,telegramSettingsReceiptSchema,telegramTestReceiptSchema,putRulesSchema,putTelegramSchema,testTelegramSchema,type PutRulesBody,type PutTelegramBody,type TestTelegramBody} from "@mmapp/contracts/alerts" +import {normalizeAlertsConfig,alertsResponseSchema,alertsRulesReceiptSchema,telegramSettingsReceiptSchema,telegramTestReceiptSchema,putTelegramSchema,testTelegramSchema,type PutRulesBody,type PutTelegramBody,type TestTelegramBody} from "@mmapp/contracts/alerts" import {requestJson} from "./http-client" type Options={signal?:AbortSignal} const signal=(options?:Options)=>{const active=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);active.throwIfAborted();return active} function parse(schema:S,raw:unknown,message:string):z.output{const result=schema.safeParse(raw);if(!result.success)throw Error(message);return result.data} export async function getAlerts(baseUrl:string,options?:Options){return parse(alertsResponseSchema,await requestJson(baseUrl,"/api/alerts",{signal:signal(options),cache:"no-store"}),"Данные оповещений не подтверждены сервером")} export async function saveAlertRules(baseUrl:string,payload:PutRulesBody,options?:Options){ - const body=parse(putRulesSchema,payload,"Проверьте правила и группы оповещений") - const ids=new Set(body.rules.map(rule=>rule.id));if(ids.size!==body.rules.length)throw Error("Повторяющиеся ID правил") - if(body.groups){const groups=new Set(body.groups.map(group=>group.id));if(groups.size!==body.groups.length||body.rules.some(rule=>rule.groupId?.trim()&&!groups.has(rule.groupId.trim())))throw Error("Повторяющиеся группы или неизвестная группа правила")} - for(const rule of body.rules){if(!(rule.targets?.length?rule.targets:[rule.target??""]).some(value=>value.trim())||!(rule.conditions?.length?rule.conditions:[rule.condition??""]).some(value=>value.trim()))throw Error("Нужны непустые объекты и условия правила")} + const body=normalizeAlertsConfig(payload) const receipt=parse(alertsRulesReceiptSchema,await requestJson(baseUrl,"/api/alerts/rules",{method:"PUT",body:JSON.stringify(body),signal:signal(options)}),"Сохранение правил не подтверждено сервером") const same=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b) if(receipt.rules.length!==body.rules.length||body.rules.some(rule=>{