diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 065d7ae0..6ae151d3 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -7,6 +7,7 @@ import {useAlertsTelegramPreview} from "@/hooks/use-alerts-telegram-preview" import {useAlertsConfigWrite} from "@/hooks/use-alerts-config-write" import {AlertsGroupForm} from "@/components/alerts-group-form" import {useAlertsTargets} from "@/hooks/use-alerts-targets" +import {AlertsOutboxPanel} from "@/components/alerts-outbox-panel" import {AlertsTelegramForm} from "@/components/alerts-telegram-form" import {AlertsTargetsStatus} from "@/components/alerts-targets-status" import { useDataSource } from "@/lib/data-source" @@ -2561,6 +2562,7 @@ function AlertsContent() { {config.busy?

Ожидаем подтверждение правил и групп…

:null} {config.unknown&&!config.busy?

Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.

:null} {isLive?:null} + {isLive?:null} {known?<> diff --git a/backend/package.json b/backend/package.json index ece4a8fe..4fce8ec8 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/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && 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:alerts-telegram": "tsx src/services/alerts-outbox-overview.test.ts && tsx src/services/alert-engine/outbox-execution.test.ts && tsx src/services/alerts-telegram-delivery.test.ts && tsx src/services/alerts-revision.test.ts && tsx src/services/alerts-overview.test.ts && 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 3aff96bc..67512c21 100644 --- a/backend/src/plugins/auth.alerts.test.ts +++ b/backend/src/plugins/auth.alerts.test.ts @@ -8,11 +8,11 @@ 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,overview:async()=>({revision,telegram:{chatId:"123",tokenConfigured:true,connected:true,messageThreadId:null},groups:[],rules:[],history:[],meta:{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,revision,groups:[],rules:[]}}}),{prefix:"/api"}) +await app.register(createAlertsRoutes({...defaultAlertsDependencies,outbox:async()=>({counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}),overview:async()=>({revision,telegram:{chatId:"123",tokenConfigured:true,connected:true,messageThreadId:null},groups:[],rules:[],history:[],meta:{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,revision,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,"if-match":'"'+revision+'"'},...(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)} + for(const [method,url] of [["GET","/api/alerts"],["GET","/api/alerts/outbox"],["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,"if-match":'"'+revision+'"'},...(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)} } assert.equal(writes,4);assert.equal(sends,2);assert.equal(reads,2) -for(const [method,url] of [["GET","/api/alerts"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401) +for(const [method,url] of [["GET","/api/alerts"],["GET","/api/alerts/outbox"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401) assert.equal(sends,2);await app.close();console.log("Alerts JWT: real read/write/admin gates before save/send/read, no token echo") diff --git a/backend/src/routes/alerts.test.ts b/backend/src/routes/alerts.test.ts index 921130b1..4d15474c 100644 --- a/backend/src/routes/alerts.test.ts +++ b/backend/src/routes/alerts.test.ts @@ -4,9 +4,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,replaceConflict=false,replaceStale=false,overviewMalformed=false +let replacements=0,replaceConflict=false,replaceStale=false,overviewMalformed=false,outboxMalformed=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,overview:async()=>overviewMalformed?{}:({revision,telegram:{chatId:stored.chatId,tokenConfigured:!!stored.botToken,connected:!!stored.botToken&&!!stored.chatId.trim(),messageThreadId:stored.messageThreadId},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),replace:async()=>{replacements++;if(replaceStale)throw Error("Правила изменились на сервере. Обновите снимок.");if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,revision,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 app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,outbox:async()=>outboxMalformed?{}:({counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}),overview:async()=>overviewMalformed?{}:({revision,telegram:{chatId:stored.chatId,tokenConfigured:!!stored.botToken,connected:!!stored.botToken&&!!stored.chatId.trim(),messageThreadId:stored.messageThreadId},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),replace:async()=>{replacements++;if(replaceStale)throw Error("Правила изменились на сервере. Обновите снимок.");if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,revision,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,[]);assert.equal(response.headers["cache-control"],"no-store");overviewMalformed=true;response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,500);assert.equal(response.json().ok,undefined);overviewMalformed=false response=await test();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{ok:true});assert.equal(reads,1);assert.equal(sends,1) @@ -21,4 +21,5 @@ for(const payload of [{rules:[invalidRule,invalidRule],groups:[]},{rules:[{...in replaceConflict=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers:{"if-match":'"'+revision+'"'},payload:{rules:[],groups:[]}});assert.equal(response.statusCode,409);assert.equal(replacements,1);assert.equal(response.json().ok,undefined) replaceConflict=false;for(const headers of [{},{"if-match":"*"},{"if-match":revision}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers,payload:{groups:[],rules:[]}});assert.equal(response.statusCode,Object.keys(headers).length?400:428)}assert.equal(replacements,1) replaceStale=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers:{"if-match":'"'+revision+'"'},payload:{groups:[],rules:[]}});assert.equal(response.statusCode,412);assert.equal(replacements,2);assert.equal(response.json().ok,undefined) +response=await app.inject({method:"GET",url:"/api/alerts/outbox"});assert.equal(response.statusCode,200);assert.equal(response.headers["cache-control"],"no-store");assert.equal(response.json().counts.total,0);outboxMalformed=true;response=await app.inject({method:"GET",url:"/api/alerts/outbox"});assert.equal(response.statusCode,500);assert.equal(response.json().counts,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 5a3ccce3..4b16a096 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -1,3 +1,4 @@ +import {getAlertsOutboxOverview} from "../services/alert-engine/outbox.js" import {sendTelegramRequest} from "../services/alerts-telegram-send.js" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { @@ -8,7 +9,7 @@ import { updateTelegramSettings, } from "../services/alerts-service.js" import { - alertsResponseSchema, + alertsResponseSchema, alertsOutboxOverviewSchema, putRulesSchema, normalizeAlertsConfig, putTelegramSchema, @@ -17,7 +18,7 @@ import { } from "@mmapp/contracts/alerts" interface Dependencies { - overview:()=>Promise;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings + outbox:()=>Promise;overview:()=>Promise;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings readTelegram:()=>Promise<{botToken:string;chatId:string;messageThreadId:number|null}> send:(input:{token:string;chatId:string;text:string;messageThreadId?:number})=>Promise<{ok:true}|{ok:false;error:string}> } @@ -28,6 +29,12 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret return reply.header("Cache-Control","no-store").send(result.data) }) + app.get("/alerts/outbox",async(_req,reply)=>{ + const parsed=alertsOutboxOverviewSchema.safeParse(await deps.outbox()) + if(!parsed.success)return reply.status(500).send({error:"Состояние очереди не подтверждено"}) + return reply.header("Cache-Control","no-store").send(parsed.data) + }) + app.put("/alerts/rules", async (req, reply) => { const parsed = putRulesSchema.safeParse(req.body) if (!parsed.success) { @@ -111,5 +118,5 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret } } -export const defaultAlertsDependencies:Dependencies={overview:getAlertsOverview,replace:replaceAlertsConfig,writeTelegram:updateTelegramSettings,readTelegram:getTelegramSettingsRow,send:input=>sendTelegramRequest(input.token,{chat_id:input.chatId,text:input.text,...(input.messageThreadId!==undefined?{message_thread_id:input.messageThreadId}:{})})} +export const defaultAlertsDependencies:Dependencies={outbox:getAlertsOutboxOverview,overview:getAlertsOverview,replace:replaceAlertsConfig,writeTelegram:updateTelegramSettings,readTelegram:getTelegramSettingsRow,send:input=>sendTelegramRequest(input.token,{chat_id:input.chatId,text:input.text,...(input.messageThreadId!==undefined?{message_thread_id:input.messageThreadId}:{})})} export default createAlertsRoutes(defaultAlertsDependencies) diff --git a/backend/src/services/alert-engine/outbox.ts b/backend/src/services/alert-engine/outbox.ts index 6368a2cc..d93f99ee 100644 --- a/backend/src/services/alert-engine/outbox.ts +++ b/backend/src/services/alert-engine/outbox.ts @@ -1,4 +1,5 @@ -import {and,asc,eq,lte} from "drizzle-orm" +import {readOutboxOverview} from "../alerts-outbox-overview.js" +import {and,asc,desc,eq,inArray,lte,sql} from "drizzle-orm" import {db} from "../../db/index.js" import {alertOutbox,alertHistory} from "../../db/schema.js" import {sendTelegramAlertMessage} from "../alerts-service.js" @@ -38,3 +39,11 @@ export async function dispatchPendingOutbox(limit=25):Promise<{sent:number;faile } return {sent,failed,held,errors} } + +export async function getAlertsOutboxOverview(){ + return readOutboxOverview[0]>[0]>({ + transaction:(work,options)=>db.transaction(work,options), + counts:async context=>{const result=await context.execute(sql`SELECT status, COUNT(*)::bigint AS count FROM alert_outbox GROUP BY status`);return result.rows}, + items:async context=>{const rows=await context.select().from(alertOutbox).where(inArray(alertOutbox.status,["processing","unknown"])).orderBy(desc(alertOutbox.nextAttemptAt),desc(alertOutbox.id)).limit(20);return rows.map(row=>{const payload=row.payloadJson;const history=typeof payload==="object"&&payload!==null&&"history" in payload?payload.history:null;const ruleName=typeof history==="object"&&history!==null&&"ruleName" in history&&typeof history.ruleName==="string"?history.ruleName:null;return {id:row.id,status:row.status,ruleName,createdAt:row.createdAt,attemptedAt:row.nextAttemptAt}})}, + }) +} diff --git a/backend/src/services/alerts-outbox-overview.test.ts b/backend/src/services/alerts-outbox-overview.test.ts new file mode 100644 index 00000000..7ddf5bb9 --- /dev/null +++ b/backend/src/services/alerts-outbox-overview.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {outboxCounts,readOutboxOverview} from "./alerts-outbox-overview.js" +test("queue counts keep unknown database states, reject unsafe counts and read one context",async()=>{ + assert.deepEqual(outboxCounts([]),{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0}) + assert.equal(outboxCounts([{status:"legacy",count:2},{status:"unknown",count:1}]).unrecognized,2) + for(const raw of [[{status:"pending",count:-1}],[{status:"pending",count:"1"}],[{status:"sent",count:Number.MAX_SAFE_INTEGER},{status:"failed",count:1}]])assert.throws(()=>outboxCounts(raw)) + const context={snapshot:1};let reads=0 + const result=await readOutboxOverview({transaction:async(work,options)=>{assert.deepEqual(options,{isolationLevel:"repeatable read",accessMode:"read only"});return work(context)},counts:async ctx=>{assert.equal(ctx,context);reads++;return [{status:"pending",count:3}]},items:async ctx=>{assert.equal(ctx,context);reads++;return []}}) + assert.equal(reads,2);assert.equal(result.counts.pending,3) +}) +test("queue incomplete held list or failed read never produces empty success",async()=>{ + for(const counts of [[{status:"unknown",count:1}],[]])await assert.rejects(readOutboxOverview({transaction:async work=>work({}),counts:async()=>counts,items:async()=>counts.length?[]:[{id:"a",status:"unknown",ruleName:null,createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T00:00:00Z"}]}),/не подтверждено/) + await assert.rejects(readOutboxOverview({transaction:async work=>work({}),counts:async()=>{throw Error("read failed")},items:async()=>[]}),/read failed/) +}) diff --git a/backend/src/services/alerts-outbox-overview.ts b/backend/src/services/alerts-outbox-overview.ts new file mode 100644 index 00000000..5f1c2516 --- /dev/null +++ b/backend/src/services/alerts-outbox-overview.ts @@ -0,0 +1,16 @@ +import {z} from "zod" +import {alertsOutboxOverviewSchema} from "@mmapp/contracts/alerts" +const countsSchema=z.array(z.object({status:z.string(),count:z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)})) +export function outboxCounts(raw:unknown){ + const rows=countsSchema.parse(raw),counts={total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0} + for(const row of rows){const key=["pending","processing","unknown","sent","failed"].includes(row.status)?row.status as "pending"|"processing"|"unknown"|"sent"|"failed":"unrecognized";counts[key]+=row.count;counts.total+=row.count} + if(!Number.isSafeInteger(counts.total))throw Error("Число записей очереди не подтверждено") + return counts +} +export async function readOutboxOverview(deps:{transaction:(work:(context:C)=>Promise>,options:{isolationLevel:"repeatable read";accessMode:"read only"})=>Promise>;counts:(context:C)=>Promise;items:(context:C)=>Promise}){ + return deps.transaction(async context=>{ + const parsed=alertsOutboxOverviewSchema.safeParse({counts:outboxCounts(await deps.counts(context)),items:await deps.items(context),itemsLimit:20}) + if(!parsed.success)throw Error("Состояние очереди не подтверждено") + return parsed.data + },{isolationLevel:"repeatable read",accessMode:"read only"}) +} diff --git a/components/alerts-outbox-panel.tsx b/components/alerts-outbox-panel.tsx new file mode 100644 index 00000000..1eb8cc72 --- /dev/null +++ b/components/alerts-outbox-panel.tsx @@ -0,0 +1,29 @@ +"use client" +import {useCallback,useEffect,useRef,useState} from "react" +import type {AlertsOutboxOverview} from "@mmapp/contracts/alerts" +import {can} from "@/lib/auth" +import {getAlertsOutbox} from "@/shared/api/alerts-outbox" +import {OpsPanel} from "@/components/ops-panel" +import {Button} from "@/components/ui/button" +export function AlertsOutboxPanel({base}:{base:string}){ + const [data,setData]=useState(null),[busy,setBusy]=useState(false),[error,setError]=useState(null),[receivedAt,setReceivedAt]=useState(null) + const active=useRef(true),revision=useRef(0),controller=useRef(null) + const refresh=useCallback(async()=>{ + if(!active.current||!can("mm:alerts:read"))return + controller.current?.abort();controller.current=new AbortController();const requestRevision=++revision.current;setBusy(true);setError(null) + const current=()=>active.current&&requestRevision===revision.current&&can("mm:alerts:read") + try{const result=await getAlertsOutbox(base,{signal:controller.current.signal});if(current()){setData(result);setReceivedAt(new Date().toISOString())}}catch(e){if(current())setError(e instanceof Error?e.message:"Не удалось получить очередь")} + finally{if(current())setBusy(false)} + },[base]) + useEffect(()=>{const generation=revision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;controller.current?.abort()}},[refresh]) + return
void refresh()}>Обновить} contentClassName="space-y-3 px-5 py-4"> + {error?

{error}{data?". Показан последний успешный снимок очереди.":""}

:null} +

{busy?"Обновление очереди…":data?"Последний успешный ответ: "+new Date(receivedAt!).toLocaleString("ru-RU"):"Данные очереди ещё не получены."}

+ {data?<> +

Ожидают: {data.counts.pending} · API принял: {data.counts.sent} · Завершены с ошибкой: {data.counts.failed}

+ {data.counts.unknown||data.counts.processing||data.counts.unrecognized?

Нужна проверка: неизвестный результат — {data.counts.unknown}, в обработке — {data.counts.processing}, нераспознанный статус — {data.counts.unrecognized}. Эти записи автоматически не переотправляются. Обработка может означать текущий запрос или прерванный процесс отправки; проверьте чат и журнал перед повтором.

:null} +

Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повторная отправка из этого экрана пока недоступна.

+ {data.items.length?
    {data.items.map(item=>
  • {item.ruleName||item.id}

    {item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}

  • )}
:null} + :null} +
+} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 852a7b63..7af7fa52 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-03 -Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.3: показать known очередь/processing/unknown в GET/UI и сохранить состояние при сбое; затем контролируемое разрешение held entries, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. +Этап 11Б в работе; последний проверенный блок 11Б.4.4.3.3; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3.4: контролируемый повтор только unknown после явной проверки оператором и защиты от конкуренции; processing удерживать до отдельной проверки остановки отправителя. Затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 3933e25b..70d8fb28 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.4.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.3.3; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.4.4.3.3 — подтверждённый снимок очереди Telegram (2026-10-03) + +- Отдельный GET /api/alerts/outbox под mm:alerts:read, no-store и строгим DTO. Counts и последние 20 processing/unknown читаются в одной repeatable read/read only transaction; сумма, безопасные числа, количество/статусы/уникальность элементов проверяются. Неизвестные legacy статусы учитываются отдельно. Ответ не содержит payload, token или lastError. +- Независимая панель на существующем OpsPanel/Frame показывает known pending/sent/failed, held состояния и время последнего успешного ответа. Initial error остаётся unknown; background error сохраняет последний снимок; queue не блокирует основной reader. Source lifetime/revision/abort отбрасывают поздние ответы A→B→A. +- Backend DI, HTTP/no-store/malformed и реальные JWT gates прошли; API strict/abort/no retry прошли. 32 профильных браузерных сценария и 12 context/ABA прошли; дополнительный мобильный прогон очереди 2/2, скриншот 320px просмотрен. Backend build, frontend typecheck без incremental, профильный lint прошли; ReUI Frame checklist применён к новой панели. +- Реальный PG snapshot/0008 migration и Telegram не проверены локально. Ручное разрешение held пока отсутствует; панель явно сообщает это. Общая приёмка Alerts не завершена. +- Следующий точный шаг 11Б.4.4.3.4: контролируемый повтор только unknown после явной проверки оператором и защиты от конкуренции; processing удерживать до отдельной проверки остановки отправителя. Затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + ## Блок 11Б.4.4.3.2 — atomic outbox claim и unknown hold (2026-10-03) - Перед POST dispatcher выполняет conditional UPDATE pending+due→processing RETURNING и проверяет ownership/id/status. Второй worker и stale selected row не могут отправить ту же запись через новый dispatcher. Claim exception не запускает POST. Payload/history/counters проверяются до отправки. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 314cec69..2c493184 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -288,3 +288,7 @@ Bot API rejected/unknown, единый stored snapshot/strict thread, no secret/ ### 11Б.4.4.3.2 — outbox orchestration (2026-10-03) Conditional atomic claim, unknown/processing hold, transactional status/history receipt, atomic dedupe enqueue и migration 0008. 7 DI/profile/build/lint прошли; engine groups/eval прошли, PG stability skipped. Реальные migration/lock/rollback/Telegram и UI/operator resolution ещё не приняты. Далее 11Б.4.4.3.3: показать known очередь/processing/unknown в GET/UI и сохранить состояние при сбое; затем контролируемое разрешение held entries, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. + +### Продолжение 2026-10-03 — 11Б.4.4.3.3 + +GET/UI очереди принят локально: независимый strict snapshot, known/unknown, сохранение при сбое и ABA; 32 browser + 12 context, API/DI/HTTP/JWT/build/typecheck/lint прошли. Реальный PG/Telegram и ручное разрешение held не приняты. Следующий блок 11Б.4.4.3.4: контролируемый повтор только unknown после явной проверки оператором и защиты от конкуренции; processing удерживать до отдельной проверки остановки отправителя. Затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12. diff --git a/packages/contracts/src/alerts.ts b/packages/contracts/src/alerts.ts index 568e6fed..8dec583a 100644 --- a/packages/contracts/src/alerts.ts +++ b/packages/contracts/src/alerts.ts @@ -131,3 +131,7 @@ export function normalizeAlertsConfig(input:PutRulesBody){ }) return {...(groups!==undefined?{groups}:{}),rules} } + +const outboxCount=z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) +export const alertsOutboxOverviewSchema=z.object({counts:z.object({total:outboxCount,pending:outboxCount,processing:outboxCount,unknown:outboxCount,sent:outboxCount,failed:outboxCount,unrecognized:outboxCount}).strict(),items:z.array(z.object({id:z.string().min(1),status:z.enum(["processing","unknown"]),ruleName:z.string().nullable(),createdAt:alertTimestampSchema,attemptedAt:alertTimestampSchema}).strict()).max(20),itemsLimit:z.literal(20)}).strict().refine(value=>value.counts.total===value.counts.pending+value.counts.processing+value.counts.unknown+value.counts.sent+value.counts.failed+value.counts.unrecognized&&value.items.length===Math.min(20,value.counts.processing+value.counts.unknown)&&value.items.filter(item=>item.status==="processing").length<=value.counts.processing&&value.items.filter(item=>item.status==="unknown").length<=value.counts.unknown&&new Set(value.items.map(item=>item.id)).size===value.items.length,"Неполное состояние очереди") +export type AlertsOutboxOverview=z.output diff --git a/shared/api/alerts-outbox.test.ts b/shared/api/alerts-outbox.test.ts new file mode 100644 index 00000000..3278db7c --- /dev/null +++ b/shared/api/alerts-outbox.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import {test} from "node:test" +import {getAlertsOutbox} from "./alerts-outbox" +const empty={counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20} +test("queue strict no-store reader distinguishes confirmed zero from malformed or missing held data",async()=>{ + const original=globalThis.fetch;try{ + globalThis.fetch=async(_url,init)=>{assert.equal(init?.cache,"no-store");return Response.json(empty)};assert.deepEqual(await getAlertsOutbox("https://backend.test"),empty) + for(const raw of [{},{...empty,counts:{...empty.counts,total:1}},{...empty,counts:{...empty.counts,total:1,unknown:1}},{...empty,secret:"private"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(getAlertsOutbox("https://backend.test"),/не подтверждено/);assert.equal(calls,1)} + const controller=new AbortController();controller.abort();let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(empty)};await assert.rejects(getAlertsOutbox("https://backend.test",{signal:controller.signal}));assert.equal(calls,0) + const late=new AbortController();globalThis.fetch=async()=>{late.abort();return Response.json(empty)};await assert.rejects(getAlertsOutbox("https://backend.test",{signal:late.signal})) + }finally{globalThis.fetch=original} +}) diff --git a/shared/api/alerts-outbox.ts b/shared/api/alerts-outbox.ts new file mode 100644 index 00000000..9c9161bc --- /dev/null +++ b/shared/api/alerts-outbox.ts @@ -0,0 +1,8 @@ +import {alertsOutboxOverviewSchema} from "@mmapp/contracts/alerts" +import {requestJson} from "./http-client" +export async function getAlertsOutbox(base:string,options?:{signal?:AbortSignal}){ + const signal=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);signal.throwIfAborted() + const parsed=alertsOutboxOverviewSchema.safeParse(await requestJson(base,"/api/alerts/outbox",{signal,cache:"no-store"}));signal.throwIfAborted() + if(!parsed.success)throw Error("Состояние очереди не подтверждено сервером") + return parsed.data +} diff --git a/tests/e2e/alerts-context.spec.ts b/tests/e2e/alerts-context.spec.ts index ae2b9927..72bcf620 100644 --- a/tests/e2e/alerts-context.spec.ts +++ b/tests/e2e/alerts-context.spec.ts @@ -1,3 +1,4 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page} from "@playwright/test" import {emptyAlerts,alertsWithRule} from "./fixtures/alerts" const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,PUT,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization,If-Match"} @@ -5,7 +6,7 @@ async function source(page:Page,value:string){const input=page.getByRole("textbo for(const failed of [false,true])test("Alerts source: ABA discards old primary and target outcomes/"+failed,async({page})=>{ const startedPaths=new Set();let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}})) - await page.route("**/api/**",async r=>{const url=new URL(r.request().url()),path=url.pathname;if(path==="/api/auth/config")return r.fulfill({headers,json:{required:false}}) + await page.route("**/api/**",async r=>{const url=new URL(r.request().url()),path=url.pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({headers,json:{required:false}}) if(old&&url.port==="8000"&&["/api/alerts","/api/bgp/sessions","/api/filters/gre-tunnels"].includes(path)){started++;startedPaths.add(path);await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD ALERTS FAILED"}}:{headers,json:path==="/api/alerts"?{...emptyAlerts,telegram:{...emptyAlerts.telegram,chatId:"OLD CHAT"}}:path==="/api/bgp/sessions"?[{serverName:"old",name:"peer"}]:{tunnels:[{name:"old",serverId:"1"}]}})}catch{}finally{completed++};return} return r.fulfill({headers,json:path==="/api/alerts"?emptyAlerts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) try{await page.goto("/alerts");await expect.poll(()=>startedPaths.size).toBe(3);await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();release();await expect.poll(()=>completed).toBe(started);await expect(page.getByText("OLD ALERTS FAILED",{exact:true})).toHaveCount(0);await expect(page.locator('input[value="OLD CHAT"]')).toHaveCount(0);for(const label of ["BGP","GRE"])await expect(page.getByRole("region",{name:"Каталог "+label,exact:true})).toContainText("Получено объектов: 0") @@ -15,7 +16,7 @@ for(const failed of [false,true])test("Alerts source: ABA discards old primary a const configuredTelegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9} for(const kind of ["save","test"] as const)for(const failed of [false,true])test("Telegram source: ABA retains unknown checkpoint and ignores old receipt/"+kind+"/"+failed,async({page})=>{ await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}})) - await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) await page.route("http://localhost:8000/api/alerts/telegram"+(kind==="test"?"/test":""),async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD TELEGRAM FAILED"}}:{headers,json:kind==="test"?{ok:true}:{ok:true,telegram:{...configuredTelegram,chatId:"-100draft"}}})}catch{}finally{completed=true}}) const form=()=>page.getByRole("region",{name:"Параметры Telegram",exact:true}) @@ -25,7 +26,7 @@ for(const kind of ["save","test"] as const)for(const failed of [false,true])test }) for(const failed of [false,true])test("Rules source: ABA ignores old save and retains scoped unknown checkpoint/"+failed,async({page})=>{ - await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?alertsWithRule:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) await page.route("http://localhost:8000/api/alerts/rules",async r=>{calls++;started=true;const body=r.request().postDataJSON();await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD RULES FAILED"}}:{headers,json:{ok:true,revision:"2".repeat(64),groups:body.groups,rules:body.rules.map((row:object)=>({...row,lastFiredAt:null}))}})}catch{}finally{completed=true}}) const toggle=()=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true}) @@ -33,8 +34,17 @@ for(const failed of [false,true])test("Rules source: ABA ignores old save and re }) for(const failed of [false,true])test("Preview source: late ABA receipt cannot clear Telegram checkpoint/"+failed,async({page})=>{ - await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...alertsWithRule,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({headers,json:emptyOutbox});return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...alertsWithRule,telegram:configuredTelegram}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) let started=false,completed=false,calls=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) await page.route("http://localhost:8000/api/alerts/telegram/test",async r=>{calls++;started=true;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD PREVIEW FAILED"}}:{headers,json:{ok:true}})}catch{}finally{completed=true}}) try{await page.goto("/alerts");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await source(page,"http://localhost:8000");await page.goto("/alerts");await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();await expect(page.getByRole("dialog")).toContainText("Результат Telegram неизвестен");release();await expect.poll(()=>completed).toBe(true);await expect(page.getByRole("dialog").getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();await expect(page.getByRole("dialog")).not.toContainText("API подтвердил приём");await expect(page.getByRole("dialog")).not.toContainText("OLD PREVIEW FAILED");expect(calls).toBe(1)}finally{release()} }) + +for(const failed of [false,true])test("Queue source: ABA ignores old held list or error/"+failed,async({page})=>{ + let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise(resolve=>release=resolve) + await page.route("**/health",r=>r.fulfill({headers,json:{status:"ok"}}));await page.route("**/api/**",async r=>{ + const url=new URL(r.request().url()),path=url.pathname + if(path==="/api/alerts/outbox"&&old&&url.port==="8000"){started++;await gate;try{await r.fulfill(failed?{headers,status:503,json:{error:"OLD QUEUE FAILED"}}:{headers,json:{...emptyOutbox,counts:{...emptyOutbox.counts,total:1,unknown:1},items:[{id:"old",status:"unknown",ruleName:"OLD HELD",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T00:00:00Z"}]}})}catch{}finally{completed++};return} + return r.fulfill({headers,json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/alerts/outbox"?emptyOutbox:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + try{await page.goto("/alerts");await expect.poll(()=>started).toBeGreaterThan(0);await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await page.goto("/settings");await source(page,"http://localhost:8101");old=false;await source(page,"http://localhost:8000");await page.goto("/alerts");const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Ожидают: 0");release();await expect.poll(()=>completed).toBe(started);await expect(panel).not.toContainText("OLD QUEUE");await expect(panel).not.toContainText("OLD HELD");await expect(panel).toContainText("Ожидают: 0")}finally{release()} +}) diff --git a/tests/e2e/alerts-history.spec.ts b/tests/e2e/alerts-history.spec.ts index 0a92ca9b..6252d4aa 100644 --- a/tests/e2e/alerts-history.spec.ts +++ b/tests/e2e/alerts-history.spec.ts @@ -1,6 +1,7 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect} from "@playwright/test" import {emptyAlerts} from "./fixtures/alerts" test("Alerts history distinguishes synthesized monitoring from notification API acceptance and unknown outcome",async({page})=>{ const common={severity:"warning",message:"Fixture history",firedAt:"2026-10-03T00:00:00.000Z"} - await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,history:[{...common,id:"live",ruleName:"Live fixture",sent:true,source:"live"},{...common,id:"db",ruleName:"Accepted fixture",sent:true,source:"db"},{...common,id:"unknown",ruleName:"Unknown fixture",sent:false,source:"db"}]}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");for(const text of ["Мониторинг","API принял","Не подтверждено"])await expect(page.getByText(text,{exact:true})).toBeVisible();await expect(page.getByText("✓ отправлено",{exact:true})).toHaveCount(0);await expect(page.getByText("✗ ошибка",{exact:true})).toHaveCount(0) + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});return r.fulfill({json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?{...emptyAlerts,history:[{...common,id:"live",ruleName:"Live fixture",sent:true,source:"live"},{...common,id:"db",ruleName:"Accepted fixture",sent:true,source:"db"},{...common,id:"unknown",ruleName:"Unknown fixture",sent:false,source:"db"}]}:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");for(const text of ["Мониторинг","API принял","Не подтверждено"])await expect(page.getByText(text,{exact:true})).toBeVisible();await expect(page.getByText("✓ отправлено",{exact:true})).toHaveCount(0);await expect(page.getByText("✗ ошибка",{exact:true})).toHaveCount(0) }) diff --git a/tests/e2e/alerts-outbox.spec.ts b/tests/e2e/alerts-outbox.spec.ts new file mode 100644 index 00000000..12adc34b --- /dev/null +++ b/tests/e2e/alerts-outbox.spec.ts @@ -0,0 +1,11 @@ +import {test,expect,type Page} from "@playwright/test" +import {emptyAlerts,emptyOutbox} from "./fixtures/alerts" +async function setup(page:Page,reply:()=>{status?:number;json:unknown}){ + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/alerts/outbox"?reply():{json:path==="/api/auth/config"?{required:false}:path==="/api/alerts"?emptyAlerts:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts") +} +test("Queue initial failure is unknown, retry confirms zero",async({page})=>{ + let fail=true;await setup(page,()=>({json:fail?{}:emptyOutbox}));const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel.getByRole("alert")).toContainText("не подтверждено");await expect(panel).not.toContainText("Ожидают: 0");await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();fail=false;await panel.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(panel).toContainText("Ожидают: 0");await expect(panel.getByRole("alert")).toHaveCount(0) +}) +test("Queue held states retain confirmed snapshot after failure and fit mobile",async({page})=>{ + await page.setViewportSize({width:320,height:1000});let fail=false;const held={...emptyOutbox,counts:{...emptyOutbox.counts,total:4,pending:1,processing:1,unknown:1,unrecognized:1},items:[{id:"a",status:"unknown",ruleName:"Held fixture",createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T01:00:00Z"},{id:"b",status:"processing",ruleName:null,createdAt:"2026-10-03T00:00:00Z",attemptedAt:"2026-10-03T02:00:00Z"}]};await setup(page,()=>fail?{status:503,json:{error:"QUEUE UNAVAILABLE"}}:{json:held});const panel=page.getByRole("region",{name:"Очередь Telegram",exact:true});await expect(panel).toContainText("Held fixture");await expect(panel).toContainText("автоматически не переотправляются");fail=true;await panel.getByRole("button",{name:"Обновить очередь Telegram"}).click();await expect(panel.getByRole("alert")).toContainText("Показан последний успешный снимок");await expect(panel).toContainText("Ожидают: 1");const bounds=await panel.boundingBox();expect(bounds!.x).toBeGreaterThanOrEqual(0);expect(bounds!.x+bounds!.width).toBeLessThanOrEqual(320);await panel.screenshot({path:"test-results/alerts-outbox-320.png"}) +}) diff --git a/tests/e2e/alerts-preview.spec.ts b/tests/e2e/alerts-preview.spec.ts index 2dbe16e6..f31e7cec 100644 --- a/tests/e2e/alerts-preview.spec.ts +++ b/tests/e2e/alerts-preview.spec.ts @@ -1,7 +1,8 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page,type Route} from "@playwright/test" import {alertsWithRule} from "./fixtures/alerts" const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9} -async function setup(page:Page,send:(route:Route)=>Promise){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill({json:{...alertsWithRule,telegram}});if(path==="/api/alerts/telegram/test")return send(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true})).toBeEnabled();await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click()} +async function setup(page:Page,send:(route:Route)=>Promise){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill({json:{...alertsWithRule,telegram}});if(path==="/api/alerts/telegram/test")return send(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true})).toBeEnabled();await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click()} test("Rule preview: strict acceptance keeps unsaved form and excludes concurrent save",async({page})=>{ let calls=0,payload:unknown,release!:()=>void;const gate=new Promise(resolve=>release=resolve) try{await setup(page,async r=>{calls++;payload=r.request().postDataJSON();await gate;return r.fulfill({json:{ok:true}})});const dialog=page.getByRole("dialog");await dialog.getByRole("textbox",{name:"Название правила",exact:true}).fill("Unsaved preview");await dialog.getByRole("button",{name:"Тест в Telegram",exact:true}).click();await expect.poll(()=>calls).toBe(1);await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await expect(dialog.getByRole("button",{name:"Тест в Telegram",exact:true})).toBeDisabled();release();await expect(dialog).toContainText("Telegram API подтвердил приём тестового правила. Проверьте его в чате.");await expect(dialog.getByRole("textbox",{name:"Название правила",exact:true})).toHaveValue("Unsaved preview");expect(payload).toMatchObject({chatId:telegram.chatId,messageThreadId:9,rulePreview:{name:"Unsaved preview",targets:["router-fixture"],conditionLine:"перешёл в offline"}});expect(payload).not.toHaveProperty("token");expect(calls).toBe(1)}finally{release()} diff --git a/tests/e2e/alerts-reader.spec.ts b/tests/e2e/alerts-reader.spec.ts index 50b1e299..6813d80b 100644 --- a/tests/e2e/alerts-reader.spec.ts +++ b/tests/e2e/alerts-reader.spec.ts @@ -1,5 +1,6 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page} from "@playwright/test" const empty={revision:"1".repeat(64),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}} -async function setup(page:Page,reply:()=>{status?:number;json:unknown}){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill(reply());if(path==="/api/bgp/sessions"||path==="/api/servers")return r.fulfill({json:[]});if(path==="/api/filters/gre-tunnels")return r.fulfill({json:{tunnels:[]}});return r.fulfill({status:503,json:{error:"outside alerts fixture"}})});await page.goto("/alerts")} +async function setup(page:Page,reply:()=>{status?:number;json:unknown}){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill(reply());if(path==="/api/bgp/sessions"||path==="/api/servers")return r.fulfill({json:[]});if(path==="/api/filters/gre-tunnels")return r.fulfill({json:{tunnels:[]}});return r.fulfill({status:503,json:{error:"outside alerts fixture"}})});await page.goto("/alerts")} for(const malformed of [{},{...empty,rules:null}])test("Alerts reader: malformed live response does not show demo rules or configured Telegram/"+JSON.stringify(malformed),async({page})=>{await setup(page,()=>({json:malformed}));await expect(page.getByText("Данные оповещений не подтверждены сервером",{exact:true})).toBeVisible();await expect(page.getByText("Данные оповещений ещё не получены.",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Добавить правило",exact:true})).toBeDisabled();await expect(page.getByText("Всего: 0",{exact:true})).toHaveCount(0);await expect(page.getByText("Bot Token",{exact:true})).toHaveCount(0)}) test("Alerts reader: confirmed empty is distinct from unknown and background failure retains snapshot",async({page})=>{let fail=false;await setup(page,()=>fail?{status:503,json:{error:"Refresh unavailable"}}:{json:empty});await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible();fail=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByText("Refresh unavailable",{exact:true})).toBeVisible();await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible()}) diff --git a/tests/e2e/alerts-rules.spec.ts b/tests/e2e/alerts-rules.spec.ts index 45b5efba..d3bab4cf 100644 --- a/tests/e2e/alerts-rules.spec.ts +++ b/tests/e2e/alerts-rules.spec.ts @@ -1,7 +1,8 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page,type Route} from "@playwright/test" import {alertsWithRule,alertsRule} from "./fixtures/alerts" async function setup(page:Page,write:(route:Route)=>Promise,required=false){ - await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts")return r.fulfill({json:alertsWithRule});if(path==="/api/alerts/rules")return write(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByText("Всего: 1",{exact:true})).toBeVisible() + await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts")return r.fulfill({json:alertsWithRule});if(path==="/api/alerts/rules")return write(r);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})});await page.goto("/alerts");await expect(page.getByText("Всего: 1",{exact:true})).toBeVisible() } function receipt(r:Route){const body=r.request().postDataJSON();return {ok:true,revision:"2".repeat(64),groups:body.groups,rules:body.rules.map((rule:object)=>({...rule,lastFiredAt:null}))}} const toggle=(page:Page)=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true}) diff --git a/tests/e2e/alerts-targets.spec.ts b/tests/e2e/alerts-targets.spec.ts index 859d7f1b..97c28fd8 100644 --- a/tests/e2e/alerts-targets.spec.ts +++ b/tests/e2e/alerts-targets.spec.ts @@ -1,8 +1,9 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page,type Route} from "@playwright/test" import {emptyAlerts} from "./fixtures/alerts" async function setup(page:Page,secondary:(route:Route,path:string)=>Promise,required=false){ await page.route("**/health",r=>r.fulfill({json:{status:"ok"}})) - await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts")return r.fulfill({json:emptyAlerts});return secondary(r,path)}) + await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts")return r.fulfill({json:emptyAlerts});return secondary(r,path)}) await page.goto("/alerts") } test("Alerts catalogs: delayed BGP does not block primary or GRE",async({page})=>{ diff --git a/tests/e2e/alerts-telegram.spec.ts b/tests/e2e/alerts-telegram.spec.ts index 6a351af7..85ca01a9 100644 --- a/tests/e2e/alerts-telegram.spec.ts +++ b/tests/e2e/alerts-telegram.spec.ts @@ -1,9 +1,10 @@ +import {emptyOutbox} from "./fixtures/alerts" import {test,expect,type Page,type Route} from "@playwright/test" import {emptyAlerts} from "./fixtures/alerts" const telegram={chatId:"-100fixture",tokenConfigured:true,connected:true,messageThreadId:9} async function setup(page:Page,handler:(route:Route,path:string)=>Promise,required=false){ await page.route("**/health",r=>r.fulfill({json:{status:"ok"}})) - await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts"||path.startsWith("/api/alerts/telegram"))return handler(r,path);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) + await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/alerts/outbox")return r.fulfill({json:emptyOutbox});if(path==="/api/auth/config")return r.fulfill({json:{required}});if(path==="/api/alerts"||path.startsWith("/api/alerts/telegram"))return handler(r,path);return r.fulfill({json:path==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:[]})}) await page.goto("/alerts");await expect(page.getByRole("textbox",{name:"Chat ID по умолчанию",exact:true})).toHaveValue(telegram.chatId) } const form=(page:Page)=>page.getByRole("region",{name:"Параметры Telegram",exact:true}) diff --git a/tests/e2e/fixtures/alerts.ts b/tests/e2e/fixtures/alerts.ts index 2790c09f..b647fa2d 100644 --- a/tests/e2e/fixtures/alerts.ts +++ b/tests/e2e/fixtures/alerts.ts @@ -2,3 +2,5 @@ export const emptyAlerts={revision:"1".repeat(64),telegram:{chatId:"",tokenConfi export const alertsRule={id:"rule-fixture",name:"Fixture rule",type:"server",target:"router-fixture",targets:["router-fixture"],groupId:"group-fixture",condition:"перешёл в offline",conditions:["перешёл в offline"],severity:"critical",enabled:true,cooldown:"5м",confirmStabilitySec:5,recoveryMode:"conditional",recoveryStabilitySec:17,chatId:"",lastFiredAt:null} export const alertsGroup={id:"group-fixture",name:"Fixture group",combineMode:"any",enabled:true,cooldownOverride:null} export const alertsWithRule={...emptyAlerts,rules:[alertsRule],groups:[alertsGroup],meta:{...emptyAlerts.meta,servers:["router-fixture"],serversDetail:[{name:"router-fixture",host:"192.0.2.1",site:"",country:"",type:""}]}} + +export const emptyOutbox={counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}