fix(alerts): показать подтверждённое состояние очереди сообщений
This commit is contained in:
1 parent
b1ad9515fa
commit
89c31289bd
24 files changed
+166
-22
No files matched your search
@@ -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?<p role="status" className="text-xs">Ожидаем подтверждение правил и групп…</p>:null}
|
||||
{config.unknown&&!config.busy?<div className="space-y-2"><p role="status" className="text-xs">Результат сохранения правил неизвестен. Запрос мог выполниться; проверьте правила и группы в БД перед повтором. Обновление списка не разрешает повтор автоматически.</p><Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={!config.enabled||operationBusy} onClick={config.review}>Я проверил правила — разрешить новый запрос</Button></div>:null}
|
||||
{isLive?<AlertsTargetsStatus catalogs={catalogs}/>:null}
|
||||
{isLive?<AlertsOutboxPanel base={backendUrl}/>:null}
|
||||
{known?<>
|
||||
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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<unknown>;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings
|
||||
outbox:()=>Promise<unknown>;overview:()=>Promise<unknown>;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)
|
||||
@@ -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<Parameters<Parameters<typeof db.transaction>[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}})},
|
||||
})
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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<C>(deps:{transaction:(work:(context:C)=>Promise<ReturnType<typeof alertsOutboxOverviewSchema.parse>>,options:{isolationLevel:"repeatable read";accessMode:"read only"})=>Promise<ReturnType<typeof alertsOutboxOverviewSchema.parse>>;counts:(context:C)=>Promise<unknown>;items:(context:C)=>Promise<unknown>}){
|
||||
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"})
|
||||
}
|
||||
@@ -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<AlertsOutboxOverview|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[receivedAt,setReceivedAt]=useState<string|null>(null)
|
||||
const active=useRef(true),revision=useRef(0),controller=useRef<AbortController|null>(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 <section aria-label="Очередь Telegram" className="min-w-0"><OpsPanel title="Очередь Telegram" description="Приём сообщения API и доставка в чат — разные состояния." headerRight={<Button type="button" variant="outline" size="sm" aria-label="Обновить очередь Telegram" disabled={busy} onClick={()=>void refresh()}>Обновить</Button>} contentClassName="space-y-3 px-5 py-4">
|
||||
{error?<p role="alert" className="text-xs text-destructive break-words">{error}{data?". Показан последний успешный снимок очереди.":""}</p>:null}
|
||||
<p role="status" className="text-xs text-muted-foreground">{busy?"Обновление очереди…":data?"Последний успешный ответ: "+new Date(receivedAt!).toLocaleString("ru-RU"):"Данные очереди ещё не получены."}</p>
|
||||
{data?<>
|
||||
<p className="text-sm">Ожидают: {data.counts.pending} · API принял: {data.counts.sent} · Завершены с ошибкой: {data.counts.failed}</p>
|
||||
{data.counts.unknown||data.counts.processing||data.counts.unrecognized?<p role="status" className="text-xs break-words">Нужна проверка: неизвестный результат — {data.counts.unknown}, в обработке — {data.counts.processing}, нераспознанный статус — {data.counts.unrecognized}. Эти записи автоматически не переотправляются. Обработка может означать текущий запрос или прерванный процесс отправки; проверьте чат и журнал перед повтором.</p>:null}
|
||||
<p className="text-xs text-muted-foreground">Показано {data.items.length} из {data.counts.processing+data.counts.unknown} удержанных записей. Повторная отправка из этого экрана пока недоступна.</p>
|
||||
{data.items.length?<ul className="space-y-2">{data.items.map(item=><li key={item.id} className="break-words text-xs"><p className="font-medium">{item.ruleName||item.id}</p><p className="text-muted-foreground">{item.status==="unknown"?"Результат неизвестен":"Запрос начат, завершение не подтверждено"} · {new Date(item.attemptedAt).toLocaleString("ru-RU")}</p></li>)}</ul>:null}
|
||||
</>:null}
|
||||
</OpsPanel></section>
|
||||
}
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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 проверяются до отправки.
|
||||
|
||||
@@ -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.
|
||||
@@ -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<typeof alertsOutboxOverviewSchema>
|
||||
@@ -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}
|
||||
})
|
||||
@@ -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<unknown>(base,"/api/alerts/outbox",{signal,cache:"no-store"}));signal.throwIfAborted()
|
||||
if(!parsed.success)throw Error("Состояние очереди не подтверждено сервером")
|
||||
return parsed.data
|
||||
}
|
||||
@@ -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<string>();let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise<void>(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<void>(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<void>(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<void>(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<void>(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()}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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"})
|
||||
})
|
||||
@@ -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<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({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<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({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<void>(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()}
|
||||
|
||||
@@ -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()})
|
||||
@@ -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<unknown>,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})
|
||||
|
||||
@@ -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<unknown>,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})=>{
|
||||
|
||||
@@ -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<unknown>,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})
|
||||
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user