From 0bcac655ea7c5bdd3afcbc878862bf9ef2f8c712 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 17:00:53 +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=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8C=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 4 +++- docs/ux-ui-progress.md | 10 +++++++- docs/ux-ui-stage-11b.md | 2 ++ packages/contracts/src/alerts.ts | 25 ++++++++++++++++++++ shared/api/alerts.test.ts | 40 ++++++++++++++++++++++++++++++++ shared/api/alerts.ts | 35 ++++++++++++++++++++++++++++ 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 shared/api/alerts.test.ts create mode 100644 shared/api/alerts.ts diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 82fab5fb..d8a5e554 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.3.6.2.3.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.1: строгие contracts/API оповещений; затем backend и UI приёмка Alerts. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. +Этап 11Б в работе; последний проверенный блок 11Б.4.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.2.1: backend Telegram selective atomic save/RETURNING, DI HTTP/JWT/send receipts; затем rules concurrency и Alerts UI. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). @@ -304,3 +304,5 @@ REST запрос → ожидание → подтверждённый output / 2026-10-03: завершён 11Б.3.6.2.3.1, отдельная DB форма/lock/checkpoint/адаптивность; 10 E2E, API/proxy tests и build прошли. Далее source/ABA/unmount и приёмка DB. 2026-10-03: 11Б.3.6.2.3.2 — 6 source и 18 соседних E2E прошли; база принята в локальных границах. Далее 11Б.4.1 Alerts contracts/API. + +2026-10-03: 11Б.4.1 — строгие Alerts output schemas/API и сверка save receipts; 5 unit/builds/typecheck/lint прошли. Module пока не подключён. Далее 11Б.4.2.1 backend Telegram. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 9e797bba..f67261b3 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Б.3.6.2.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.4.1 — строгие Alerts contracts/API (2026-10-03) + +- Аудит существующего UI: GET/PUT/POST принимают unchecked JSON; load заменяет Telegram draft и маскирует вторичные каталоги пустыми, save error вызывает load; целевая metadata иногда подменяется demo значениями. UI/backend пока не мигрированы этим блоком и не приняты. +- Общие output schemas проверяют public Telegram без token, literal booleans/null, arrays и даты, уникальные rule/group IDs и ссылки групп. connected сохраняет существующую семантику «token+chat настроены», не доказывает доступность Telegram или доставку. +- Новый API module проверяет GET/PUT rules/PUT Telegram/test receipts, false/204/malformed/contradictory responses не подтверждают операции. Selective Telegram receipt сверяется с chat/thread/secretConfigured; rules receipt сверяется с ID/полями и нормализованными targets/conditions, не со сводными computed labels. Локальная проверка не посылает пустые/дублирующиеся rules/groups/неизвестные group refs, пустой Telegram patch или invalid thread. +- Caller abort проверяется до fetch, 30s timeout и отсутствие автоматического повтора. Начальный unit выявил, что pre-aborted fetch всё ещё вызывал stub; helper исправлен, повтор прошёл. 5 API unit групп, contracts/backend build, typecheck и профильный lint прошли. Новый module ещё не подключён к UI; реальные Telegram/PG не вызывались. +- Следующий блок 11Б.4.2.1: backend Telegram selective atomic update/strict input/RETURNING без lost update и false receipt; DI HTTP/JWT test/send result. Затем rules transaction/readback concurrency и UI migrations/source/drafts/rights/adaptive. + ## Блок 11Б.3.6.2.3.2 — source и локальная приёмка базы (2026-10-03) - 6 browser source сценариев прошли: поздний success/error backup при ABA, переход в demo, принадлежность unknown checkpoint backend при ABA, reload во время POST с поздним success/error без публикации/повтора. Смена источника не доказывает server cancellation; checkpoint старого источника остаётся. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index b1be1643..0bb78258 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -234,3 +234,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S | 11Б.3.6.2.3.1 База приложения | [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | Источник и доступ → скачать / выбрать файл → подтверждение → результат + warnings; неизвестный результат → проверка данных → разрешение нового запроса | Завершён 11Б.3.6.2.3.2: 6 source/ABA/reload и 18 General/DB E2E прошли, DB принят локально. Реальный PG/UDP worker/Portal не принят. Далее 11Б.4.1 — strict Alerts contracts/API, затем backend/UI. + +Завершён 11Б.4.1: строгие Alerts output/API/receipt сверки, 5 unit/contracts/backend build/typecheck/lint прошли. UI/backend пока прежние. Далее 11Б.4.2.1 — atomic selective Telegram save/RETURNING и DI HTTP/JWT/test results; затем rules/UI. diff --git a/packages/contracts/src/alerts.ts b/packages/contracts/src/alerts.ts index faffcbcf..85b45133 100644 --- a/packages/contracts/src/alerts.ts +++ b/packages/contracts/src/alerts.ts @@ -82,3 +82,28 @@ export type PutRulesBody = z.infer export type PutTelegramBody = z.infer export type RulePreviewTelegram = z.infer export type TestTelegramBody = z.infer + +// Public response schemas: configured credentials do not prove Telegram connectivity/delivery. +const alertTimestampSchema = z.string().min(1).refine(value => Number.isFinite(Date.parse(value)), "Некорректное время оповещения") +export const telegramPublicSchema = z.object({ + chatId:z.string(),tokenConfigured:z.boolean(),connected:z.boolean(),messageThreadId:z.number().int().positive().nullable(), +}).strict().refine(value => value.connected === (value.tokenConfigured && Boolean(value.chatId.trim())), "Противоречивое состояние параметров Telegram") +export const alertRuleResponseSchema = z.object({ + id:z.string().min(1),name:z.string().min(1),type:alertTypeSchema,target:z.string(),targets:z.array(z.string().min(1)),groupId:z.string().nullable(),condition:z.string(),conditions:z.array(z.string().min(1)),severity:alertSeveritySchema,enabled:z.boolean(),cooldown:alertCooldownSchema, + confirmStabilitySec:z.number().int().min(0).max(86400).nullable(),recoveryMode:recoveryModeSchema,recoveryStabilitySec:z.number().int().min(0).max(86400).nullable(),chatId:z.string(),lastFiredAt:alertTimestampSchema.nullable(), +}).strict() +export const alertHistoryResponseSchema = z.object({id:z.string().min(1),ruleName:z.string(),severity:alertSeveritySchema,message:z.string(),sent:z.boolean(),firedAt:alertTimestampSchema,source:z.enum(["db","live"])}).strict() +export const alertsMetaResponseSchema = z.object({ + servers:z.array(z.string()),greClients:z.array(z.string()),probeTargets:z.array(z.string()),rttLossTargets:z.array(z.string()),trafficServers:z.array(z.string()), + serversDetail:z.array(z.object({name:z.string(),host:z.string(),site:z.string(),country:z.string(),type:z.string()}).strict()), +}).strict() +const alertsConfigResponseShape = {groups:z.array(alertGroupSchema.strict()),rules:z.array(alertRuleResponseSchema)} +function validConfig(value:{groups:{id:string}[];rules:{id:string;groupId:string|null}[]}):boolean { + const groups=new Set(value.groups.map(row=>row.id)),rules=new Set(value.rules.map(row=>row.id)) + return groups.size===value.groups.length&&rules.size===value.rules.length&&value.rules.every(row=>row.groupId===null||groups.has(row.groupId)) +} +export const alertsResponseSchema = z.object({telegram:telegramPublicSchema,...alertsConfigResponseShape,history:z.array(alertHistoryResponseSchema),meta:alertsMetaResponseSchema}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила") +export const alertsRulesReceiptSchema = z.object({ok:z.literal(true),...alertsConfigResponseShape}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила") +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 diff --git a/shared/api/alerts.test.ts b/shared/api/alerts.test.ts new file mode 100644 index 00000000..27145918 --- /dev/null +++ b/shared/api/alerts.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {getAlerts,saveAlertRules,saveAlertsTelegram,testAlertsTelegram} from "./alerts" +const telegram={chatId:"",tokenConfigured:false,connected:false,messageThreadId:null} +const empty={telegram,groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}} +test("Alerts reader preserves known empty/false/null and rejects malformed collections, public secrets and dates",async()=>{ + const original=globalThis.fetch;try{ + globalThis.fetch=async()=>Response.json(empty);assert.deepEqual(await getAlerts("https://backend.test"),empty) + for(const raw of [{},{...empty,rules:null},{...empty,groups:undefined},{...empty,telegram:{...telegram,connected:true}},{...empty,telegram:{...telegram,token:"secret"}},{...empty,history:[{id:"h",ruleName:"r",severity:"info",message:"m",sent:false,firedAt:"invalid",source:"db"}]}]){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(getAlerts("https://backend.test"),/не подтверждены/)} + }finally{globalThis.fetch=original} +}) +test("Alerts mutations require literal complete receipts and never repeat unknown requests",async()=>{ + const original=globalThis.fetch;try{ + for(const call of [()=>saveAlertRules("https://backend.test",{rules:[],groups:[]}),()=>saveAlertsTelegram("https://backend.test",{chatId:""}),()=>testAlertsTelegram("https://backend.test")])for(const raw of [{},{ok:false},{ok:"true"},{ok:true,error:"failed"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(call(),/не подтвержден/);assert.equal(calls,1)} + globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[]});assert.equal((await saveAlertRules("https://backend.test",{groups:[],rules:[]})).ok,true) + globalThis.fetch=async()=>Response.json({ok:true});assert.deepEqual(await testAlertsTelegram("https://backend.test"),{ok:true}) + }finally{globalThis.fetch=original} +}) +test("Telegram selective save confirms sent values and rejects mismatched configured secret/thread/chat",async()=>{ + const original=globalThis.fetch;try{ + globalThis.fetch=async(_url,init)=>{assert.deepEqual(JSON.parse(init?.body as string),{chatId:" 123 ",messageThreadId:null});return Response.json({ok:true,telegram:{...telegram,chatId:"123"}})} + assert.equal((await saveAlertsTelegram("https://backend.test",{chatId:" 123 ",messageThreadId:null})).telegram.chatId,"123") + globalThis.fetch=async()=>Response.json({ok:true,telegram}) + for(const patch of [{chatId:"123"},{messageThreadId:1},{token:"fixture-secret"}])await assert.rejects(saveAlertsTelegram("https://backend.test",patch),/не совпадают/) + assert.equal((await saveAlertsTelegram("https://backend.test",{token:null})).telegram.tokenConfigured,false) + }finally{globalThis.fetch=original} +}) +test("invalid Telegram thread/empty patch and abort do not send requests",async()=>{ + const original=globalThis.fetch;let calls=0;try{globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertsTelegram("https://backend.test",{}),/Нет изменённых/);for(const id of [0,-1,1.5,NaN]){await assert.rejects(saveAlertsTelegram("https://backend.test",{messageThreadId:id}),/Проверьте/);await assert.rejects(testAlertsTelegram("https://backend.test",{messageThreadId:id}),/Проверьте/)}const controller=new AbortController();controller.abort();await assert.rejects(getAlerts("https://backend.test",{signal:controller.signal}));assert.equal(calls,0)}finally{globalThis.fetch=original} +}) + +test("rules save checks IDs, fields and normalized target/condition arrays without substituting computed labels",async()=>{ + const original=globalThis.fetch;try{ + const rule={id:"r",name:"Server",type:"server" as const,target:"host",targets:[" host "],groupId:null,condition:"down",conditions:[" down "],severity:"critical" as const,enabled:false,cooldown:"5м" as const,confirmStabilitySec:0,recoveryMode:"never" as const,recoveryStabilitySec:null,chatId:""} + const saved={...rule,targets:["host"],conditions:["down"],confirmStabilitySec:null,lastFiredAt:null} + globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[saved]});assert.equal((await saveAlertRules("https://backend.test",{rules:[rule],groups:[]})).rules[0].enabled,false) + for(const wrong of [{...saved,enabled:true},{...saved,targets:["other"]},{...saved,name:"Other"}]){globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[wrong]});await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule],groups:[]}),/не совпадают/)} + let calls=0;globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule,rule]}),/Повторяющиеся/);await assert.rejects(saveAlertRules("https://backend.test",{rules:[{...rule,targets:[" "],conditions:[" "]}]}),/непустые/);assert.equal(calls,0) + }finally{globalThis.fetch=original} +}) diff --git a/shared/api/alerts.ts b/shared/api/alerts.ts new file mode 100644 index 00000000..69518c29 --- /dev/null +++ b/shared/api/alerts.ts @@ -0,0 +1,35 @@ +import {z} from "zod" +import {alertsResponseSchema,alertsRulesReceiptSchema,telegramSettingsReceiptSchema,telegramTestReceiptSchema,putRulesSchema,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 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=>{ + const saved=receipt.rules.find(row=>row.id===rule.id) + if(!saved)return true + 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) + const expected={name:rule.name,type:rule.type,targets,conditions,groupId:rule.groupId?.trim()||null,severity:rule.severity,enabled:rule.enabled,cooldown:rule.cooldown,confirmStabilitySec:rule.confirmStabilitySec||null,recoveryMode:rule.recoveryMode??"always",recoveryStabilitySec:rule.recoveryStabilitySec||null,chatId:rule.chatId} + return Object.entries(expected).some(([key,value])=>!same(saved[key as keyof typeof saved],value)) + })||body.groups!==undefined&&(receipt.groups.length!==body.groups.length||body.groups.some(group=>{const saved=receipt.groups.find(row=>row.id===group.id);return !saved||Object.entries({...group,name:group.name.trim()||group.id}).some(([key,value])=>!same(saved[key as keyof typeof saved],value))})))throw Error("Сохранённые правила или группы не совпадают с отправленными") + return receipt +} +export async function saveAlertsTelegram(baseUrl:string,payload:PutTelegramBody,options?:Options){ + const body=parse(putTelegramSchema,payload,"Проверьте параметры Telegram") + if(Object.keys(body).length===0)throw Error("Нет изменённых параметров Telegram") + const receipt=parse(telegramSettingsReceiptSchema,await requestJson(baseUrl,"/api/alerts/telegram",{method:"PUT",body:JSON.stringify(body),signal:signal(options)}),"Сохранение параметров Telegram не подтверждено сервером") + if(body.chatId!==undefined&&receipt.telegram.chatId!==body.chatId.trim()||body.messageThreadId!==undefined&&receipt.telegram.messageThreadId!==body.messageThreadId||body.token!==undefined&&receipt.telegram.tokenConfigured!==Boolean(body.token?.trim()))throw Error("Сохранённые параметры Telegram не совпадают с отправленными") + return receipt +} +export async function testAlertsTelegram(baseUrl:string,payload:TestTelegramBody={},options?:Options){ + const body=parse(testTelegramSchema,payload,"Проверьте параметры тестового сообщения Telegram") + return parse(telegramTestReceiptSchema,await requestJson(baseUrl,"/api/alerts/telegram/test",{method:"POST",body:JSON.stringify(body),signal:signal(options)}),"Отправка тестового сообщения не подтверждена сервером") +}