fix(alerts): отклонять запись устаревшего снимка правил
This commit is contained in:
1 parent
c5c1c2812d
commit
382e318b97
23 files changed
+126
-43
No files matched your search
@@ -1529,7 +1529,7 @@ function AddRuleSheet({
|
|||||||
initialForm,
|
initialForm,
|
||||||
editRuleId,
|
editRuleId,
|
||||||
onTestRuleTelegram,
|
onTestRuleTelegram,
|
||||||
testTelegramDisabled, saving,saveDisabled,saveError,review,testOutcome,testError,testReview,
|
testTelegramDisabled, saving,saveDisabled,saveError,review,onRefresh,testOutcome,testError,testReview,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -1538,6 +1538,7 @@ function AddRuleSheet({
|
|||||||
saving:boolean
|
saving:boolean
|
||||||
saveDisabled:boolean
|
saveDisabled:boolean
|
||||||
saveError:string|null
|
saveError:string|null
|
||||||
|
onRefresh:()=>void
|
||||||
review:React.ReactNode
|
review:React.ReactNode
|
||||||
testOutcome:string|null
|
testOutcome:string|null
|
||||||
testError:string|null
|
testError:string|null
|
||||||
@@ -2199,6 +2200,7 @@ function AddRuleSheet({
|
|||||||
Тест в Telegram
|
Тест в Telegram
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{saveError?<Button type="button" size="sm" variant="outline" className="h-auto whitespace-normal" disabled={saving} onClick={onRefresh}>Обновить снимок — сохранить черновик</Button>:null}
|
||||||
{testError?<p role="alert" className="w-full text-xs text-destructive break-words">{testError}</p>:null}
|
{testError?<p role="alert" className="w-full text-xs text-destructive break-words">{testError}</p>:null}
|
||||||
{testOutcome?<p role="status" className="w-full text-xs break-words">{testOutcome}</p>:null}
|
{testOutcome?<p role="status" className="w-full text-xs break-words">{testOutcome}</p>:null}
|
||||||
{testReview}
|
{testReview}
|
||||||
@@ -2242,6 +2244,7 @@ function AlertsContent() {
|
|||||||
return {...baseMeta,bgpPeers:catalogs.bgp.data?.map(row=>row.serverName+": "+row.name),greTunnelDetail,greTunnelTargets:greTunnelDetail?.map(row=>row.targetLabel)}
|
return {...baseMeta,bgpPeers:catalogs.bgp.data?.map(row=>row.serverName+": "+row.name),greTunnelDetail,greTunnelTargets:greTunnelDetail?.map(row=>row.targetLabel)}
|
||||||
},[baseMeta,catalogs.bgp.data,catalogs.servers.data,catalogs.gre.data])
|
},[baseMeta,catalogs.bgp.data,catalogs.servers.data,catalogs.gre.data])
|
||||||
|
|
||||||
|
const [configRevision,setConfigRevision]=useState<string|null>(null)
|
||||||
const [known,setKnown]=useState(!isLive)
|
const [known,setKnown]=useState(!isLive)
|
||||||
const [receivedAt,setReceivedAt]=useState<string|null>(null)
|
const [receivedAt,setReceivedAt]=useState<string|null>(null)
|
||||||
const pageActive=useRef(true),reader=useRef<AbortController|null>(null),revision=useRef(0)
|
const pageActive=useRef(true),reader=useRef<AbortController|null>(null),revision=useRef(0)
|
||||||
@@ -2262,7 +2265,7 @@ function AlertsContent() {
|
|||||||
const [operationBusy,setOperationBusy]=useState(false)
|
const [operationBusy,setOperationBusy]=useState(false)
|
||||||
const beginOperation=()=>{if(!pageActive.current||operationLock.current)return false;operationLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setOperationBusy(true);return true}
|
const beginOperation=()=>{if(!pageActive.current||operationLock.current)return false;operationLock.current=true;reader.current?.abort();revision.current++;setLoadBusy(false);setOperationBusy(true);return true}
|
||||||
const endOperation=()=>{operationLock.current=false;setOperationBusy(false)}
|
const endOperation=()=>{operationLock.current=false;setOperationBusy(false)}
|
||||||
const config=useAlertsConfigWrite(backendUrl,isLive,known&&backendStatus===true,beginOperation,endOperation)
|
const config=useAlertsConfigWrite(backendUrl,isLive,known&&backendStatus===true,configRevision,beginOperation,endOperation)
|
||||||
const preview=useAlertsTelegramPreview(backendUrl,isLive,known&&backendStatus===true&&tokenConfigured,beginOperation,endOperation)
|
const preview=useAlertsTelegramPreview(backendUrl,isLive,known&&backendStatus===true&&tokenConfigured,beginOperation,endOperation)
|
||||||
const rulesSaveBusy=config.busy
|
const rulesSaveBusy=config.busy
|
||||||
const writesDisabled=!config.enabled||operationBusy||config.unknown
|
const writesDisabled=!config.enabled||operationBusy||config.unknown
|
||||||
@@ -2317,7 +2320,7 @@ function AlertsContent() {
|
|||||||
try {
|
try {
|
||||||
const data = await getAlerts(backendUrl,{signal:controller.signal})
|
const data = await getAlerts(backendUrl,{signal:controller.signal})
|
||||||
if(!pageActive.current||requestRevision!==revision.current)return
|
if(!pageActive.current||requestRevision!==revision.current)return
|
||||||
setKnown(true);setReceivedAt(new Date().toISOString())
|
setConfigRevision(data.revision);setKnown(true);setReceivedAt(new Date().toISOString())
|
||||||
setTokenConfigured(data.telegram.tokenConfigured)
|
setTokenConfigured(data.telegram.tokenConfigured)
|
||||||
setTg({
|
setTg({
|
||||||
token: "",
|
token: "",
|
||||||
@@ -2348,7 +2351,7 @@ function AlertsContent() {
|
|||||||
const flushAlertsConfig=useCallback(async(nextRules:AlertRule[],nextGroups:AlertGroup[])=>{
|
const flushAlertsConfig=useCallback(async(nextRules:AlertRule[],nextGroups:AlertGroup[])=>{
|
||||||
const receipt=await config.save({rules:nextRules.map(toApiRuleBody),groups:nextGroups.map(toApiGroupBody)})
|
const receipt=await config.save({rules:nextRules.map(toApiRuleBody),groups:nextGroups.map(toApiGroupBody)})
|
||||||
if(!receipt||!pageActive.current)return false
|
if(!receipt||!pageActive.current)return false
|
||||||
setRules(mapApiRules(receipt.rules));setGroups(mapApiGroups(receipt.groups));return true
|
setConfigRevision(receipt.revision);setRules(mapApiRules(receipt.rules));setGroups(mapApiGroups(receipt.groups));return true
|
||||||
},[config])
|
},[config])
|
||||||
|
|
||||||
const testRuleTelegramFromForm=async(p:RuleTelegramTestPayload)=>preview.test({
|
const testRuleTelegramFromForm=async(p:RuleTelegramTestPayload)=>preview.test({
|
||||||
@@ -2809,6 +2812,7 @@ function AlertsContent() {
|
|||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
onClose={closeRuleSheet}
|
onClose={closeRuleSheet}
|
||||||
onSave={handleRuleSheetSave}
|
onSave={handleRuleSheetSave}
|
||||||
|
onRefresh={()=>void load()}
|
||||||
saving={rulesSaveBusy} saveDisabled={writesDisabled} saveError={config.error??loadError}
|
saving={rulesSaveBusy} saveDisabled={writesDisabled} saveError={config.error??loadError}
|
||||||
review={config.unknown&&!config.busy?<div className="w-full 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}
|
review={config.unknown&&!config.busy?<div className="w-full 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}
|
||||||
typeTargets={typeTargets}
|
typeTargets={typeTargets}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "npm run build -w @mmapp/contracts && tsc",
|
"build": "npm run build -w @mmapp/contracts && tsc",
|
||||||
"test:alerts-telegram": "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-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",
|
"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",
|
"start": "node dist/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {alertsConfigRevision} from "../services/alerts-revision.js"
|
||||||
|
const revision=alertsConfigRevision({groups:[],rules:[]})
|
||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
import {SignJWT} from "jose"
|
import {SignJWT} from "jose"
|
||||||
@@ -6,10 +8,10 @@ const {default:authPlugin,requireAuth}=await import("./auth.js")
|
|||||||
const {createAlertsRoutes,defaultAlertsDependencies}=await import("../routes/alerts.js")
|
const {createAlertsRoutes,defaultAlertsDependencies}=await import("../routes/alerts.js")
|
||||||
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
|
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
|
||||||
let writes=0,sends=0,reads=0
|
let writes=0,sends=0,reads=0
|
||||||
await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>({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,groups:[],rules:[]}}}),{prefix:"/api"})
|
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"})
|
||||||
for(const permission of ["mm:alerts:read","mm:alerts:write","mm:alerts:admin","mm:settings:admin"]){
|
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"))
|
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
|
||||||
for(const [method,url] of [["GET","/api/alerts"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)}
|
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)}
|
||||||
}
|
}
|
||||||
assert.equal(writes,4);assert.equal(sends,2);assert.equal(reads,2)
|
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"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import {alertsConfigRevision} from "../services/alerts-revision.js"
|
||||||
|
const revision=alertsConfigRevision({groups:[],rules:[]})
|
||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import Fastify from "fastify"
|
import Fastify from "fastify"
|
||||||
import {createAlertsRoutes,defaultAlertsDependencies} from "./alerts.js"
|
import {createAlertsRoutes,defaultAlertsDependencies} from "./alerts.js"
|
||||||
import {executeTelegramSettingsUpdate} from "../services/alerts-telegram-settings.js"
|
import {executeTelegramSettingsUpdate} from "../services/alerts-telegram-settings.js"
|
||||||
let replacements=0,replaceConflict=false,overviewMalformed=false
|
let replacements=0,replaceConflict=false,replaceStale=false,overviewMalformed=false
|
||||||
let stored={botToken:"fixture-secret",chatId:"123",messageThreadId:10 as number|null},reads=0,writes=0,sends=0,failSend=false,failWrite=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?{}:({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(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,groups:[],rules:[]}},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
|
const 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 test=()=>app.inject({method:"POST",url:"/api/alerts/telegram/test",payload:{}})
|
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
|
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)
|
response=await test();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{ok:true});assert.equal(reads,1);assert.equal(sends,1)
|
||||||
@@ -16,5 +18,7 @@ failWrite=true;response=await app.inject({method:"PUT",url:"/api/alerts/telegram
|
|||||||
stored={...stored,botToken:""};response=await test();assert.equal(response.statusCode,400);assert.equal(sends,2)
|
stored={...stored,botToken:""};response=await test();assert.equal(response.statusCode,400);assert.equal(sends,2)
|
||||||
const invalidRule={id:"r",name:"Server",type:"server",target:"host",condition:"down",severity:"critical",enabled:false,cooldown:"5м",chatId:""}
|
const invalidRule={id:"r",name:"Server",type:"server",target:"host",condition:"down",severity:"critical",enabled:false,cooldown:"5м",chatId:""}
|
||||||
for(const payload of [{rules:[invalidRule,invalidRule],groups:[]},{rules:[{...invalidRule,targets:[" "]}],groups:[]},{rules:[{...invalidRule,groupId:"missing"}],groups:[]}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload});assert.equal(response.statusCode,400)}assert.equal(replacements,0)
|
for(const payload of [{rules:[invalidRule,invalidRule],groups:[]},{rules:[{...invalidRule,targets:[" "]}],groups:[]},{rules:[{...invalidRule,groupId:"missing"}],groups:[]}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload});assert.equal(response.statusCode,400)}assert.equal(replacements,0)
|
||||||
replaceConflict=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload:{rules:[],groups:[]}});assert.equal(response.statusCode,409);assert.equal(replacements,1);assert.equal(response.json().ok,undefined)
|
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)
|
||||||
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")
|
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")
|
||||||
@@ -34,6 +34,10 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
|
|||||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||||
}
|
}
|
||||||
try { normalizeAlertsConfig(parsed.data) } catch { return reply.status(400).send({error:"Проверьте объекты, условия и группы правил"}) }
|
try { normalizeAlertsConfig(parsed.data) } catch { return reply.status(400).send({error:"Проверьте объекты, условия и группы правил"}) }
|
||||||
|
const ifMatch=req.headers["if-match"]
|
||||||
|
if(ifMatch===undefined)return reply.status(428).send({error:"Обновите снимок правил перед сохранением"})
|
||||||
|
if(typeof ifMatch!=="string"||!/^"[a-f0-9]{64}"$/.test(ifMatch))return reply.status(400).send({error:"Некорректная ревизия правил"})
|
||||||
|
const expectedRevision=ifMatch.slice(1,-1)
|
||||||
const groups = parsed.data.groups
|
const groups = parsed.data.groups
|
||||||
const rules = parsed.data.rules.map((r) => ({
|
const rules = parsed.data.rules.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -57,8 +61,8 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
|
|||||||
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
|
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
|
||||||
chatId: r.chatId,
|
chatId: r.chatId,
|
||||||
}))
|
}))
|
||||||
try {return reply.send(await deps.replace({ groups, rules }))}
|
try {return reply.send(await deps.replace({ groups, rules,expectedRevision }))}
|
||||||
catch(error){if(error instanceof Error&&error.message.includes("уже выполняется"))return reply.status(409).send({error:error.message});throw error}
|
catch(error){if(error instanceof Error&&error.message.includes("Правила изменились"))return reply.status(412).send({error:error.message});if(error instanceof Error&&error.message.includes("уже выполняется"))return reply.status(409).send({error:error.message});throw error}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/alerts/telegram", async (req, reply) => {
|
app.put("/alerts/telegram", async (req, reply) => {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import {test} from "node:test"
|
import {test} from "node:test"
|
||||||
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
|
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
|
||||||
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
|
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
|
||||||
const initial={groups:[{id:"g",name:"Group",combineMode:"all" as const,enabled:false,cooldownOverride:null}],rules:[]}
|
const initial={groups:[{id:"g",name:"Group",combineMode:"all" as const,enabled:false,cooldownOverride:null}],rules:[]}
|
||||||
function fixture(){let state=structuredClone(initial),locks:boolean|undefined=true,failWrite=false,badRead=false,wrongRead=false;const calls:string[]=[];return {calls,state:()=>state,conflict:()=>locks=false,unknown:()=>locks=undefined,fail:()=>failWrite=true,bad:()=>badRead=true,wrong:()=>wrongRead=true,deps:{transaction:async(work:(context:typeof initial)=>Promise<unknown>)=>{calls.push("begin");const context=structuredClone(state);try{const receipt=await work(context);state=context;calls.push("commit");return receipt as never}catch(error){calls.push("rollback");throw error}},lock:async()=>{calls.push("lock");return locks},groups:async(context:typeof initial)=>{calls.push("groups");return context.groups},write:async(context:typeof initial,value:ReturnType<typeof normalizeAlertsConfig>)=>{calls.push("write");context.groups=value.groups!;if(failWrite)throw Error("insert failed")},read:async(context:typeof initial)=>{calls.push("read");return badRead?{ok:false}:wrongRead?{ok:true,...context,groups:initial.groups}:{ok:true,...context}}}}}
|
function fixture(){let state=structuredClone(initial),locks:boolean|undefined=true,failWrite=false,badRead=false,wrongRead=false;const calls:string[]=[];return {calls,state:()=>state,conflict:()=>locks=false,unknown:()=>locks=undefined,fail:()=>failWrite=true,bad:()=>badRead=true,wrong:()=>wrongRead=true,deps:{transaction:async(work:(context:typeof initial)=>Promise<unknown>)=>{calls.push("begin");const context=structuredClone(state);try{const receipt=await work(context);state=context;calls.push("commit");return receipt as never}catch(error){calls.push("rollback");throw error}},lock:async()=>{calls.push("lock");return locks},revision:async(context:typeof initial)=>{calls.push("revision");return alertsConfigRevision(context)},groups:async(context:typeof initial)=>{calls.push("groups");return context.groups},write:async(context:typeof initial,value:ReturnType<typeof normalizeAlertsConfig>)=>{calls.push("write");context.groups=value.groups!;if(failWrite)throw Error("insert failed")},read:async(context:typeof initial)=>{calls.push("read");return badRead?{ok:false}:wrongRead?{ok:true,revision:alertsConfigRevision({...context,groups:initial.groups}),...context,groups:initial.groups}:{ok:true,revision:alertsConfigRevision(context),...context}}}}}
|
||||||
test("omitted groups are read and confirmed inside one locked transaction",async()=>{const f=fixture();const receipt=await executeAlertsConfigReplacement({rules:[]},f.deps);assert.equal(receipt.ok,true);assert.deepEqual(receipt.groups,initial.groups);assert.deepEqual(f.calls,["begin","lock","groups","write","read","commit"])})
|
test("omitted groups are read and confirmed inside one locked transaction",async()=>{const f=fixture();const receipt=await executeAlertsConfigReplacement({rules:[]},f.deps);assert.equal(receipt.ok,true);assert.deepEqual(receipt.groups,initial.groups);assert.deepEqual(f.calls,["begin","lock","groups","write","read","commit"])})
|
||||||
test("write/readback failures roll back and never repeat",async()=>{for(const step of ["write","read"]){const f=fixture();if(step==="write")f.fail();else f.bad();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps));assert.deepEqual(f.state(),initial);assert.equal(f.calls.filter(s=>s==="write").length,1);assert.equal(f.calls.includes("commit"),false);assert.equal(f.calls.at(-1),"rollback")}})
|
test("write/readback failures roll back and never repeat",async()=>{for(const step of ["write","read"]){const f=fixture();if(step==="write")f.fail();else f.bad();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps));assert.deepEqual(f.state(),initial);assert.equal(f.calls.filter(s=>s==="write").length,1);assert.equal(f.calls.includes("commit"),false);assert.equal(f.calls.at(-1),"rollback")}})
|
||||||
test("false or unknown lock prevents every destructive write",async()=>{for(const unknown of [false,true]){const f=fixture();if(unknown)f.unknown();else f.conflict();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/выполняется|не подтверждена/);assert.equal(f.calls.includes("write"),false);assert.deepEqual(f.state(),initial)}})
|
test("false or unknown lock prevents every destructive write",async()=>{for(const unknown of [false,true]){const f=fixture();if(unknown)f.unknown();else f.conflict();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/выполняется|не подтверждена/);assert.equal(f.calls.includes("write"),false);assert.deepEqual(f.state(),initial)}})
|
||||||
|
|
||||||
test("literal success with another config rolls back instead of confirming unrelated data",async()=>{const f=fixture();f.wrong();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/не совпадают/);assert.deepEqual(f.state(),initial);assert.equal(f.calls.at(-1),"rollback")})
|
test("literal success with another config rolls back instead of confirming unrelated data",async()=>{const f=fixture();f.wrong();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/не совпадают/);assert.deepEqual(f.state(),initial);assert.equal(f.calls.at(-1),"rollback")})
|
||||||
|
|
||||||
|
test("stale revision is rejected after a preceding commit before every destructive write",async()=>{
|
||||||
|
const f=fixture(),expectedRevision=alertsConfigRevision(f.state())
|
||||||
|
const receipt=await executeAlertsConfigReplacement({groups:[],rules:[],expectedRevision},f.deps);assert.equal(receipt.revision,alertsConfigRevision(f.state()));assert.notEqual(receipt.revision,expectedRevision)
|
||||||
|
const previousWrites=f.calls.filter(call=>call==="write").length
|
||||||
|
await assert.rejects(executeAlertsConfigReplacement({...initial,expectedRevision},f.deps),/Правила изменились/);assert.equal(f.calls.filter(call=>call==="write").length,previousWrites);assert.deepEqual(f.state(),{groups:[],rules:[]});assert.deepEqual(f.calls.slice(-4),["begin","lock","revision","rollback"])
|
||||||
|
})
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
import {normalizeAlertsConfig,alertsRulesReceiptSchema,type PutRulesBody} from "@mmapp/contracts/alerts"
|
import {normalizeAlertsConfig,alertsRulesReceiptSchema,type PutRulesBody} from "@mmapp/contracts/alerts"
|
||||||
type Receipt=ReturnType<typeof alertsRulesReceiptSchema.parse>
|
type Receipt=ReturnType<typeof alertsRulesReceiptSchema.parse>
|
||||||
type Normalized=ReturnType<typeof normalizeAlertsConfig>&{groups:NonNullable<ReturnType<typeof normalizeAlertsConfig>["groups"]>}
|
type Normalized=ReturnType<typeof normalizeAlertsConfig>&{groups:NonNullable<ReturnType<typeof normalizeAlertsConfig>["groups"]>}
|
||||||
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<Receipt>)=>Promise<Receipt>;lock:(context:C)=>Promise<boolean|undefined>;groups:(context:C)=>Promise<Normalized["groups"]>;write:(context:C,value:Normalized)=>Promise<void>;read:(context:C)=>Promise<unknown>}
|
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<Receipt>)=>Promise<Receipt>;lock:(context:C)=>Promise<boolean|undefined>;revision:(context:C)=>Promise<string>;groups:(context:C)=>Promise<Normalized["groups"]>;write:(context:C,value:Normalized)=>Promise<void>;read:(context:C)=>Promise<unknown>}
|
||||||
export async function executeAlertsConfigReplacement<C>(input:PutRulesBody,deps:Dependencies<C>):Promise<Receipt>{
|
export async function executeAlertsConfigReplacement<C>(input:PutRulesBody&{expectedRevision?:string},deps:Dependencies<C>):Promise<Receipt>{
|
||||||
normalizeAlertsConfig(input)
|
const {expectedRevision,...body}=input
|
||||||
|
normalizeAlertsConfig(body)
|
||||||
return deps.transaction(async context=>{
|
return deps.transaction(async context=>{
|
||||||
const locked=await deps.lock(context)
|
const locked=await deps.lock(context)
|
||||||
if(locked===false)throw Error("Сохранение правил уже выполняется")
|
if(locked===false)throw Error("Сохранение правил уже выполняется")
|
||||||
if(locked!==true)throw Error("Блокировка правил не подтверждена")
|
if(locked!==true)throw Error("Блокировка правил не подтверждена")
|
||||||
const normalized=normalizeAlertsConfig({...input,groups:input.groups??await deps.groups(context)}) as Normalized
|
if(expectedRevision!==undefined&&await deps.revision(context)!==expectedRevision)throw Error("Правила изменились на сервере. Обновите снимок и сравните его с черновиком перед сохранением.")
|
||||||
|
const normalized=normalizeAlertsConfig({...body,groups:body.groups??await deps.groups(context)}) as Normalized
|
||||||
await deps.write(context,normalized)
|
await deps.write(context,normalized)
|
||||||
const parsed=alertsRulesReceiptSchema.safeParse(await deps.read(context))
|
const parsed=alertsRulesReceiptSchema.safeParse(await deps.read(context))
|
||||||
if(!parsed.success)throw Error("Сохранённая конфигурация оповещений не подтверждена")
|
if(!parsed.success)throw Error("Сохранённая конфигурация оповещений не подтверждена")
|
||||||
const receipt=parsed.data
|
const receipt=parsed.data
|
||||||
|
if(receipt.revision!==alertsConfigRevision(receipt))throw Error("Ревизия сохранённой конфигурации не подтверждена")
|
||||||
const match=(expected:Record<string,unknown>,actual:Record<string,unknown>|undefined)=>actual!==undefined&&Object.entries(expected).every(([key,value])=>JSON.stringify(actual[key])===JSON.stringify(value))
|
const match=(expected:Record<string,unknown>,actual:Record<string,unknown>|undefined)=>actual!==undefined&&Object.entries(expected).every(([key,value])=>JSON.stringify(actual[key])===JSON.stringify(value))
|
||||||
if(receipt.rules.length!==normalized.rules.length||receipt.groups.length!==normalized.groups.length||normalized.rules.some(rule=>!match(rule,receipt.rules.find(row=>row.id===rule.id)))||normalized.groups.some(group=>!match(group,receipt.groups.find(row=>row.id===group.id))))throw Error("Сохранённые правила или группы не совпадают с отправленными")
|
if(receipt.rules.length!==normalized.rules.length||receipt.groups.length!==normalized.groups.length||normalized.rules.some(rule=>!match(rule,receipt.rules.find(row=>row.id===rule.id)))||normalized.groups.some(group=>!match(group,receipt.groups.find(row=>row.id===group.id))))throw Error("Сохранённые правила или группы не совпадают с отправленными")
|
||||||
return receipt
|
return receipt
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
import assert from "node:assert/strict"
|
import assert from "node:assert/strict"
|
||||||
import {test} from "node:test"
|
import {test} from "node:test"
|
||||||
import type {AlertsResponse} from "@mmapp/contracts/alerts"
|
import type {AlertsResponse} from "@mmapp/contracts/alerts"
|
||||||
import {readAlertsOverview,alertsOverviewTransaction,buildAlertsMeta} from "./alerts-overview.js"
|
import {readAlertsOverview,alertsOverviewTransaction,buildAlertsMeta} from "./alerts-overview.js"
|
||||||
const empty:AlertsResponse={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:buildAlertsMeta([],[])}
|
const empty:AlertsResponse={revision:alertsConfigRevision({groups:[],rules:[]}),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:buildAlertsMeta([],[])}
|
||||||
test("Alerts overview uses one context with repeatable read/read only and rejects partial snapshots",async()=>{
|
test("Alerts overview uses one context with repeatable read/read only and rejects partial snapshots",async()=>{
|
||||||
const context={snapshot:structuredClone(empty)},calls:string[]=[];let transactions=0
|
const context={snapshot:structuredClone(empty)},calls:string[]=[];let transactions=0
|
||||||
const read=<K extends keyof typeof empty>(key:K)=>async(value:typeof context)=>{assert.equal(value,context);calls.push(key);return value.snapshot[key]}
|
const read=<K extends keyof typeof empty>(key:K)=>async(value:typeof context)=>{assert.equal(value,context);calls.push(key);return value.snapshot[key]}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
import {alertsResponseSchema,type AlertsResponse} from "@mmapp/contracts/alerts"
|
import {alertsResponseSchema,type AlertsResponse} from "@mmapp/contracts/alerts"
|
||||||
export const alertsOverviewTransaction={isolationLevel:"repeatable read",accessMode:"read only"} as const
|
export const alertsOverviewTransaction={isolationLevel:"repeatable read",accessMode:"read only"} as const
|
||||||
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<AlertsResponse>,options:typeof alertsOverviewTransaction)=>Promise<AlertsResponse>;telegram:(context:C)=>Promise<unknown>;groups:(context:C)=>Promise<unknown>;rules:(context:C)=>Promise<unknown>;history:(context:C)=>Promise<unknown>;meta:(context:C)=>Promise<unknown>}
|
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<AlertsResponse>,options:typeof alertsOverviewTransaction)=>Promise<AlertsResponse>;telegram:(context:C)=>Promise<unknown>;groups:(context:C)=>Promise<unknown>;rules:(context:C)=>Promise<unknown>;history:(context:C)=>Promise<unknown>;meta:(context:C)=>Promise<unknown>}
|
||||||
export async function readAlertsOverview<C>(deps:Dependencies<C>):Promise<AlertsResponse>{
|
export async function readAlertsOverview<C>(deps:Dependencies<C>):Promise<AlertsResponse>{
|
||||||
return deps.transaction(async context=>{
|
return deps.transaction(async context=>{
|
||||||
const telegram=await deps.telegram(context)
|
const telegram=await deps.telegram(context)
|
||||||
const parsed=alertsResponseSchema.safeParse({telegram,groups:await deps.groups(context),rules:await deps.rules(context),history:await deps.history(context),meta:await deps.meta(context)})
|
const groups=await deps.groups(context),rules=await deps.rules(context)
|
||||||
|
let revision:string
|
||||||
|
try{revision=alertsConfigRevision({groups,rules})}catch{throw Error("Снимок оповещений не подтверждён")}
|
||||||
|
const parsed=alertsResponseSchema.safeParse({revision,telegram,groups,rules,history:await deps.history(context),meta:await deps.meta(context)})
|
||||||
if(!parsed.success)throw Error("Снимок оповещений не подтверждён")
|
if(!parsed.success)throw Error("Снимок оповещений не подтверждён")
|
||||||
return parsed.data
|
return parsed.data
|
||||||
},alertsOverviewTransaction)
|
},alertsOverviewTransaction)
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {test} from "node:test"
|
||||||
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
|
const rule={id:"r",name:"Router",type:"server",targets:["router"],conditions:["offline"],severity:"critical",enabled:true,cooldown:"5м",chatId:"",recoveryMode:"conditional",recoveryStabilitySec:17}
|
||||||
|
test("Alerts revision is deterministic across row/property order and ignores notification history",()=>{
|
||||||
|
const first={groups:[],rules:[{...rule,id:"b"},{...rule,id:"a"}]};const revision=alertsConfigRevision(first)
|
||||||
|
assert.match(revision,/^[a-f0-9]{64}$/);assert.equal(alertsConfigRevision({rules:[{lastFiredAt:"2026-10-03",...rule,id:"a"},{...rule,id:"b"}],groups:[]}),revision)
|
||||||
|
for(const patch of [{enabled:false},{targets:["other"]},{conditions:["online"]},{recoveryMode:"never"},{recoveryStabilitySec:18}])assert.notEqual(alertsConfigRevision({groups:[],rules:[{...rule,...patch,id:"a"},{...rule,id:"b"}]}),revision)
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {createHash} from "node:crypto"
|
||||||
|
import {putRulesSchema,normalizeAlertsConfig} from "@mmapp/contracts/alerts"
|
||||||
|
export function alertsConfigRevision(input:unknown){
|
||||||
|
const parsed=putRulesSchema.parse(input)
|
||||||
|
if(parsed.groups===undefined)throw Error("Для ревизии нужен полный снимок групп")
|
||||||
|
const normalized=normalizeAlertsConfig(parsed)
|
||||||
|
const sort=<T extends {id:string}>(rows:T[])=>[...rows].sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0)
|
||||||
|
return createHash("sha256").update(JSON.stringify({groups:sort(normalized.groups!),rules:sort(normalized.rules)})).digest("hex")
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {alertsConfigRevision} from "./alerts-revision.js"
|
||||||
import {readAlertsOverview,buildAlertsMeta} from "./alerts-overview.js"
|
import {readAlertsOverview,buildAlertsMeta} from "./alerts-overview.js"
|
||||||
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
|
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
|
||||||
import {sendTelegramRequest} from "./alerts-telegram-send.js"
|
import {sendTelegramRequest} from "./alerts-telegram-send.js"
|
||||||
@@ -205,11 +206,12 @@ export type ReplaceAlertGroupInput = {
|
|||||||
|
|
||||||
/** Атомарно заменяет группы, правила и строки targets. */
|
/** Атомарно заменяет группы, правила и строки targets. */
|
||||||
type AlertsWriteContext=Parameters<Parameters<typeof db.transaction>[0]>[0]
|
type AlertsWriteContext=Parameters<Parameters<typeof db.transaction>[0]>[0]
|
||||||
export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
|
export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[];expectedRevision?:string }) {
|
||||||
const now=sql`now()`
|
const now=sql`now()`
|
||||||
return executeAlertsConfigReplacement<AlertsWriteContext>(payload, {
|
return executeAlertsConfigReplacement<AlertsWriteContext>(payload, {
|
||||||
transaction:work=>db.transaction(work),
|
transaction:work=>db.transaction(work),
|
||||||
lock:async tx=>{const result=await tx.execute(sql`SELECT pg_try_advisory_xact_lock(8723103) AS acquired`);return typeof result.rows[0]?.acquired==="boolean"?result.rows[0].acquired:undefined},
|
lock:async tx=>{const result=await tx.execute(sql`SELECT pg_try_advisory_xact_lock(8723103) AS acquired`);return typeof result.rows[0]?.acquired==="boolean"?result.rows[0].acquired:undefined},
|
||||||
|
revision:async tx=>alertsConfigRevision({groups:await listAlertGroups(tx),rules:await listAlertRules(tx)}),
|
||||||
groups:tx=>listAlertGroups(tx),
|
groups:tx=>listAlertGroups(tx),
|
||||||
write:async(tx,{groups,rules})=>{
|
write:async(tx,{groups,rules})=>{
|
||||||
await tx.delete(alertRuleConditions)
|
await tx.delete(alertRuleConditions)
|
||||||
@@ -285,7 +287,7 @@ export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
read:async tx=>({ok:true,groups:await listAlertGroups(tx),rules:await listAlertRules(tx)}),
|
read:async tx=>{const groups=await listAlertGroups(tx),rules=await listAlertRules(tx);return {ok:true,revision:alertsConfigRevision({groups,rules}),groups,rules}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 2026-10-03
|
||||||
|
|
||||||
Этап 11Б в работе; последний проверенный блок 11Б.4.4.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.2: optimistic revision/If-Match правил и групп, чтобы старый клиент не перезаписывал новый snapshot; затем outbox unknown audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
Этап 11Б в работе; последний проверенный блок 11Б.4.4.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.3: outbox audit — неизвестный результат отправки/повторы/зависшие sending и согласованность журнала; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||||
|
|
||||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -28,7 +28,7 @@
|
|||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11Б.4.4.2 — ревизия конфигурации правил (2026-10-03)
|
||||||
|
|
||||||
|
- GET/strict receipt содержит обязательную SHA-256 revision правил/групп. Canonical hash сохраняет порядок targets/conditions и policy, сортирует строки по ID, исключает history/lastFired и Telegram. Это версия конфигурации в body, не ETag полного overview с динамической историей.
|
||||||
|
- Client требует read revision, отправляет quoted If-Match; backend требует его до replace (428 missing/400 malformed). После nonblocking lock сравнивает актуальный hash внутри той же transaction до destructive write; stale получает 412, занятый lock по-прежнему 409. Readback проверяет собственную revision перед commit. Внутренний legacy replaceAlertRules без revision оставлен для совместимости; HTTP bypass отсутствует.
|
||||||
|
- UI сохраняет новую revision только из confirmed GET/receipt. 412/428 — подтверждённый отказ без unknown checkpoint; draft остаётся. Editor умеет обновить snapshot, сохраняя draft; следующая запись включает полученные concurrent rules и новую revision. После refresh пользователь должен сравнить конфликтующие поля одного правила с черновиком — автоматического merge нет.
|
||||||
|
- 1 hash + 1 stale-commit DI, профильный backend/HTTP/JWT (missing/malformed/stale), 6 API unit, 9 rules browser + 3 primary + 3 preview, 10 source/ABA, typecheck/lint и backend/frontend/contracts production builds пройдены. Промежуточные validation/lint ошибки исправлены и профильные проверки повторены. Реальная PG конкуренция/lock/isolation не доказана.
|
||||||
|
- API compatibility: frontend/contracts/backend должны обновляться вместе. Старый HTTP writer без If-Match получает 428; новый reader не принимает legacy overview без revision. Reverse proxy должен передавать If-Match. CORS backend отражает request headers; source fixtures включают If-Match. Нет push/deploy.
|
||||||
|
- Следующий точный шаг 11Б.4.4.3: outbox audit — неизвестный результат отправки/повторы/зависшие sending и согласованность журнала; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||||
|
|
||||||
## Блок 11Б.4.4.1 — согласованный GET и происхождение данных (2026-10-03)
|
## Блок 11Б.4.4.1 — согласованный GET и происхождение данных (2026-10-03)
|
||||||
|
|
||||||
- Production GET Alerts использует одну read-only repeatable-read transaction: Telegram/groups/rules/targets/conditions/last-fired/history/live issues/metadata читаются через тот же context. Raw live SQL больше не выходит в pool dbAll. GET Telegram не создаёт singleton row, отсутствующая конфигурация имеет корректное пустое состояние. Storage thread не округляется молча.
|
- Production GET Alerts использует одну read-only repeatable-read transaction: Telegram/groups/rules/targets/conditions/last-fired/history/live issues/metadata читаются через тот же context. Raw live SQL больше не выходит в pool dbAll. GET Telegram не создаёт singleton row, отсутствующая конфигурация имеет корректное пустое состояние. Storage thread не округляется молча.
|
||||||
|
|||||||
@@ -273,3 +273,8 @@ Strict preview и общий checkpoint карточки/editor без секр
|
|||||||
### 11Б.4.4.1 — GET Alerts (2026-10-03)
|
### 11Б.4.4.1 — GET Alerts (2026-10-03)
|
||||||
|
|
||||||
Read-only repeatable read/единый context без pool escape, strict snapshot/no-store, только реальные probe metadata и отдельный source истории без ложной доставки. 2 DI + backend HTTP/JWT/profile, history browser, backend build/root typecheck/lint прошли. Реальная PG isolation не доказана. Далее 11Б.4.4.2: optimistic revision/If-Match правил и групп, чтобы старый клиент не перезаписывал новый snapshot; затем outbox unknown audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
Read-only repeatable read/единый context без pool escape, strict snapshot/no-store, только реальные probe metadata и отдельный source истории без ложной доставки. 2 DI + backend HTTP/JWT/profile, history browser, backend build/root typecheck/lint прошли. Реальная PG isolation не доказана. Далее 11Б.4.4.2: optimistic revision/If-Match правил и групп, чтобы старый клиент не перезаписывал новый snapshot; затем outbox unknown audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||||
|
|
||||||
|
|
||||||
|
### 11Б.4.4.2 — revision/If-Match (2026-10-03)
|
||||||
|
|
||||||
|
Canonical configuration hash, обязательный If-Match HTTP, locked stale check до write, 412/428 без optimistic overwrite; draft refresh сохраняет concurrent rules. Hash/stale DI, HTTP/JWT, API/browser/source, builds/typecheck/lint прошли; real PG конкуренция не доказана. Обновлять frontend/contracts/backend вместе; no push. Далее 11Б.4.4.3: outbox audit — неизвестный результат отправки/повторы/зависшие sending и согласованность журнала; затем общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||||
@@ -10,12 +10,12 @@ const key=(source:string)=>"mm:alerts-rules-pending:"+source
|
|||||||
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}}
|
function unresolved(source:string){try{return pending.has(source)||sessionStorage.getItem(key(source))==="true"}catch{return pending.has(source)}}
|
||||||
function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* The memory guard survives section navigation. */}}
|
function checkpoint(source:string,value:boolean){if(value)pending.add(source);else pending.delete(source);try{if(value)sessionStorage.setItem(key(source),"true");else sessionStorage.removeItem(key(source))}catch{/* The memory guard survives section navigation. */}}
|
||||||
|
|
||||||
export function useAlertsConfigWrite(base:string,live:boolean,available:boolean,onBegin:()=>boolean,onEnd:()=>void){
|
export function useAlertsConfigWrite(base:string,live:boolean,available:boolean,expectedRevision:string|null,onBegin:()=>boolean,onEnd:()=>void){
|
||||||
const source=JSON.stringify([live,base])
|
const source=JSON.stringify([live,base])
|
||||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false)
|
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null),[outcome,setOutcome]=useState<string|null>(null),[unknown,setUnknown]=useState(false),[ready,setReady]=useState(false)
|
||||||
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
const active=useRef(true),lock=useRef(false),controller=useRef<AbortController|null>(null)
|
||||||
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setReady(true);setUnknown(unresolved(source))}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
useEffect(()=>{active.current=true;queueMicrotask(()=>{if(active.current){setReady(true);setUnknown(unresolved(source))}});return()=>{active.current=false;controller.current?.abort()}},[source])
|
||||||
const enabled=ready&&live&&available&&can("mm:alerts:write")
|
const enabled=ready&&live&&available&&!!expectedRevision&&can("mm:alerts:write")
|
||||||
async function save(payload:PutRulesBody){
|
async function save(payload:PutRulesBody){
|
||||||
if(!enabled||lock.current||unknown||!active.current||!can("mm:alerts:write"))return null
|
if(!enabled||lock.current||unknown||!active.current||!can("mm:alerts:write"))return null
|
||||||
let normalized:ReturnType<typeof normalizeAlertsConfig>
|
let normalized:ReturnType<typeof normalizeAlertsConfig>
|
||||||
@@ -24,10 +24,10 @@ export function useAlertsConfigWrite(base:string,live:boolean,available:boolean,
|
|||||||
lock.current=true;controller.current=new AbortController();setBusy(true);setError(null);setOutcome(null);checkpoint(source,true);setUnknown(true)
|
lock.current=true;controller.current=new AbortController();setBusy(true);setError(null);setOutcome(null);checkpoint(source,true);setUnknown(true)
|
||||||
const current=()=>active.current&&can("mm:alerts:write")
|
const current=()=>active.current&&can("mm:alerts:write")
|
||||||
try{
|
try{
|
||||||
const receipt=await saveAlertRules(base,normalized,{signal:controller.current.signal});if(!current())return null
|
const receipt=await saveAlertRules(base,normalized,{signal:controller.current.signal,expectedRevision:expectedRevision??undefined});if(!current())return null
|
||||||
checkpoint(source,false);setUnknown(false);setOutcome("Правила и группы сохранены в БД");return receipt
|
checkpoint(source,false);setUnknown(false);setOutcome("Правила и группы сохранены в БД");return receipt
|
||||||
}catch(e){
|
}catch(e){
|
||||||
const rejected=e instanceof ApiClientError&&[400,401,403,409,422].includes(e.status)
|
const rejected=e instanceof ApiClientError&&[400,401,403,409,412,422,428].includes(e.status)
|
||||||
if(rejected&¤t())checkpoint(source,false)
|
if(rejected&¤t())checkpoint(source,false)
|
||||||
if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат сохранения правил не получен")};return null
|
if(current()){setUnknown(!rejected);setError(e instanceof Error?e.message:"Результат сохранения правил не получен")};return null
|
||||||
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(false);onEnd()}}
|
}finally{lock.current=false;controller.current=null;if(active.current){setBusy(false);onEnd()}}
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ function validConfig(value:{groups:{id:string}[];rules:{id:string;groupId:string
|
|||||||
const groups=new Set(value.groups.map(row=>row.id)),rules=new Set(value.rules.map(row=>row.id))
|
const groups=new Set(value.groups.map(row=>row.id)),rules=new Set(value.rules.map(row=>row.id))
|
||||||
return groups.size===value.groups.length&&rules.size===value.rules.length&&value.rules.every(row=>row.groupId===null||groups.has(row.groupId))
|
return groups.size===value.groups.length&&rules.size===value.rules.length&&value.rules.every(row=>row.groupId===null||groups.has(row.groupId))
|
||||||
}
|
}
|
||||||
export const alertsResponseSchema = z.object({telegram:telegramPublicSchema,...alertsConfigResponseShape,history:z.array(alertHistoryResponseSchema),meta:alertsMetaResponseSchema}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила")
|
export const alertsRevisionSchema=z.string().regex(/^[a-f0-9]{64}$/)
|
||||||
export const alertsRulesReceiptSchema = z.object({ok:z.literal(true),...alertsConfigResponseShape}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила")
|
export const alertsResponseSchema = z.object({revision:alertsRevisionSchema,telegram:telegramPublicSchema,...alertsConfigResponseShape,history:z.array(alertHistoryResponseSchema),meta:alertsMetaResponseSchema}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила")
|
||||||
|
export const alertsRulesReceiptSchema = z.object({ok:z.literal(true),revision:alertsRevisionSchema,...alertsConfigResponseShape}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила")
|
||||||
export const telegramSettingsReceiptSchema = z.object({ok:z.literal(true),telegram:telegramPublicSchema}).strict()
|
export const telegramSettingsReceiptSchema = z.object({ok:z.literal(true),telegram:telegramPublicSchema}).strict()
|
||||||
export const telegramTestReceiptSchema = z.object({ok:z.literal(true)}).strict()
|
export const telegramTestReceiptSchema = z.object({ok:z.literal(true)}).strict()
|
||||||
export type AlertsResponse = z.output<typeof alertsResponseSchema>
|
export type AlertsResponse = z.output<typeof alertsResponseSchema>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import assert from "node:assert/strict"
|
|||||||
import {test} from "node:test"
|
import {test} from "node:test"
|
||||||
import {getAlerts,saveAlertRules,saveAlertsTelegram,testAlertsTelegram} from "./alerts"
|
import {getAlerts,saveAlertRules,saveAlertsTelegram,testAlertsTelegram} from "./alerts"
|
||||||
const telegram={chatId:"",tokenConfigured:false,connected:false,messageThreadId:null}
|
const telegram={chatId:"",tokenConfigured:false,connected:false,messageThreadId:null}
|
||||||
const empty={telegram,groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
const revision="1".repeat(64)
|
||||||
|
const empty={revision,telegram,groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
||||||
test("Alerts reader preserves known empty/false/null and rejects malformed collections, public secrets and dates",async()=>{
|
test("Alerts reader preserves known empty/false/null and rejects malformed collections, public secrets and dates",async()=>{
|
||||||
const original=globalThis.fetch;try{
|
const original=globalThis.fetch;try{
|
||||||
globalThis.fetch=async()=>Response.json(empty);assert.deepEqual(await getAlerts("https://backend.test"),empty)
|
globalThis.fetch=async()=>Response.json(empty);assert.deepEqual(await getAlerts("https://backend.test"),empty)
|
||||||
@@ -11,8 +12,8 @@ test("Alerts reader preserves known empty/false/null and rejects malformed colle
|
|||||||
})
|
})
|
||||||
test("Alerts mutations require literal complete receipts and never repeat unknown requests",async()=>{
|
test("Alerts mutations require literal complete receipts and never repeat unknown requests",async()=>{
|
||||||
const original=globalThis.fetch;try{
|
const original=globalThis.fetch;try{
|
||||||
for(const call of [()=>saveAlertRules("https://backend.test",{rules:[],groups:[]}),()=>saveAlertsTelegram("https://backend.test",{chatId:""}),()=>testAlertsTelegram("https://backend.test")])for(const raw of [{},{ok:false},{ok:"true"},{ok:true,error:"failed"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(call(),/не подтвержден/);assert.equal(calls,1)}
|
for(const call of [()=>saveAlertRules("https://backend.test",{rules:[],groups:[]},{expectedRevision:revision}),()=>saveAlertsTelegram("https://backend.test",{chatId:""}),()=>testAlertsTelegram("https://backend.test")])for(const raw of [{},{ok:false},{ok:"true"},{ok:true,error:"failed"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(call(),/не подтвержден/);assert.equal(calls,1)}
|
||||||
globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[]});assert.equal((await saveAlertRules("https://backend.test",{groups:[],rules:[]})).ok,true)
|
globalThis.fetch=async()=>Response.json({ok:true,revision,groups:[],rules:[]});assert.equal((await saveAlertRules("https://backend.test",{groups:[],rules:[]},{expectedRevision:revision})).ok,true)
|
||||||
globalThis.fetch=async()=>Response.json({ok:true});assert.deepEqual(await testAlertsTelegram("https://backend.test"),{ok:true})
|
globalThis.fetch=async()=>Response.json({ok:true});assert.deepEqual(await testAlertsTelegram("https://backend.test"),{ok:true})
|
||||||
}finally{globalThis.fetch=original}
|
}finally{globalThis.fetch=original}
|
||||||
})
|
})
|
||||||
@@ -33,8 +34,16 @@ test("rules save checks IDs, fields and normalized target/condition arrays witho
|
|||||||
const original=globalThis.fetch;try{
|
const original=globalThis.fetch;try{
|
||||||
const rule={id:"r",name:"Server",type:"server" as const,target:"host",targets:[" host "],groupId:null,condition:"down",conditions:[" down "],severity:"critical" as const,enabled:false,cooldown:"5м" as const,confirmStabilitySec:0,recoveryMode:"never" as const,recoveryStabilitySec:null,chatId:""}
|
const rule={id:"r",name:"Server",type:"server" as const,target:"host",targets:[" host "],groupId:null,condition:"down",conditions:[" down "],severity:"critical" as const,enabled:false,cooldown:"5м" as const,confirmStabilitySec:0,recoveryMode:"never" as const,recoveryStabilitySec:null,chatId:""}
|
||||||
const saved={...rule,targets:["host"],conditions:["down"],confirmStabilitySec:null,lastFiredAt:null}
|
const saved={...rule,targets:["host"],conditions:["down"],confirmStabilitySec:null,lastFiredAt:null}
|
||||||
globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[saved]});assert.equal((await saveAlertRules("https://backend.test",{rules:[rule],groups:[]})).rules[0].enabled,false)
|
globalThis.fetch=async()=>Response.json({ok:true,revision,groups:[],rules:[saved]});assert.equal((await saveAlertRules("https://backend.test",{rules:[rule],groups:[]},{expectedRevision:revision})).rules[0].enabled,false)
|
||||||
for(const wrong of [{...saved,enabled:true},{...saved,targets:["other"]},{...saved,name:"Other"}]){globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[wrong]});await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule],groups:[]}),/не совпадают/)}
|
for(const wrong of [{...saved,enabled:true},{...saved,targets:["other"]},{...saved,name:"Other"}]){globalThis.fetch=async()=>Response.json({ok:true,revision,groups:[],rules:[wrong]});await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule],groups:[]},{expectedRevision:revision}),/не совпадают/)}
|
||||||
let calls=0;globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule,rule]}),/Повторяющиеся/);await assert.rejects(saveAlertRules("https://backend.test",{rules:[{...rule,targets:[" "],conditions:[" "]}]}),/непустые/);assert.equal(calls,0)
|
let calls=0;globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule,rule]},{expectedRevision:revision}),/Повторяющиеся/);await assert.rejects(saveAlertRules("https://backend.test",{rules:[{...rule,targets:[" "],conditions:[" "]}]},{expectedRevision:revision}),/непустые/);assert.equal(calls,0)
|
||||||
|
}finally{globalThis.fetch=original}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rules client requires read revision, sends quoted If-Match and does not retry conflict",async()=>{
|
||||||
|
const original=globalThis.fetch;let calls=0;try{
|
||||||
|
globalThis.fetch=async(_url,init)=>{calls++;assert.equal(new Headers(init?.headers).get("If-Match"),'"'+revision+'"');assert.deepEqual(JSON.parse(String(init?.body)),{rules:[],groups:[]});return Response.json({error:"Fixture stale revision"},{status:412})}
|
||||||
|
await assert.rejects(saveAlertRules("https://backend.test",{groups:[],rules:[]}),/Обновите снимок/);assert.equal(calls,0)
|
||||||
|
await assert.rejects(saveAlertRules("https://backend.test",{groups:[],rules:[]},{expectedRevision:revision}),/Fixture stale revision/);assert.equal(calls,1)
|
||||||
}finally{globalThis.fetch=original}
|
}finally{globalThis.fetch=original}
|
||||||
})
|
})
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import {z} from "zod"
|
import {z} from "zod"
|
||||||
import {normalizeAlertsConfig,alertsResponseSchema,alertsRulesReceiptSchema,telegramSettingsReceiptSchema,telegramTestReceiptSchema,putTelegramSchema,testTelegramSchema,type PutRulesBody,type PutTelegramBody,type TestTelegramBody} from "@mmapp/contracts/alerts"
|
import {normalizeAlertsConfig,alertsResponseSchema,alertsRulesReceiptSchema,telegramSettingsReceiptSchema,telegramTestReceiptSchema,putTelegramSchema,testTelegramSchema,type PutRulesBody,type PutTelegramBody,type TestTelegramBody} from "@mmapp/contracts/alerts"
|
||||||
import {requestJson} from "./http-client"
|
import {requestJson} from "./http-client"
|
||||||
type Options={signal?:AbortSignal}
|
type Options={signal?:AbortSignal;expectedRevision?:string}
|
||||||
const signal=(options?:Options)=>{const active=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);active.throwIfAborted();return active}
|
const signal=(options?:Options)=>{const active=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);active.throwIfAborted();return active}
|
||||||
function parse<S extends z.ZodType>(schema:S,raw:unknown,message:string):z.output<S>{const result=schema.safeParse(raw);if(!result.success)throw Error(message);return result.data}
|
function parse<S extends z.ZodType>(schema:S,raw:unknown,message:string):z.output<S>{const result=schema.safeParse(raw);if(!result.success)throw Error(message);return result.data}
|
||||||
export async function getAlerts(baseUrl:string,options?:Options){return parse(alertsResponseSchema,await requestJson<unknown>(baseUrl,"/api/alerts",{signal:signal(options),cache:"no-store"}),"Данные оповещений не подтверждены сервером")}
|
export async function getAlerts(baseUrl:string,options?:Options){return parse(alertsResponseSchema,await requestJson<unknown>(baseUrl,"/api/alerts",{signal:signal(options),cache:"no-store"}),"Данные оповещений не подтверждены сервером")}
|
||||||
export async function saveAlertRules(baseUrl:string,payload:PutRulesBody,options?:Options){
|
export async function saveAlertRules(baseUrl:string,payload:PutRulesBody,options?:Options){
|
||||||
const body=normalizeAlertsConfig(payload)
|
const body=normalizeAlertsConfig(payload)
|
||||||
const receipt=parse(alertsRulesReceiptSchema,await requestJson<unknown>(baseUrl,"/api/alerts/rules",{method:"PUT",body:JSON.stringify(body),signal:signal(options)}),"Сохранение правил не подтверждено сервером")
|
if(!options?.expectedRevision||!/^[a-f0-9]{64}$/.test(options.expectedRevision))throw Error("Обновите снимок правил перед сохранением")
|
||||||
|
const receipt=parse(alertsRulesReceiptSchema,await requestJson<unknown>(baseUrl,"/api/alerts/rules",{method:"PUT",headers:{"If-Match":'"'+options.expectedRevision+'"'},body:JSON.stringify(body),signal:signal(options)}),"Сохранение правил не подтверждено сервером")
|
||||||
const same=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b)
|
const same=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b)
|
||||||
if(receipt.rules.length!==body.rules.length||body.rules.some(rule=>{
|
if(receipt.rules.length!==body.rules.length||body.rules.some(rule=>{
|
||||||
const saved=receipt.rules.find(row=>row.id===rule.id)
|
const saved=receipt.rules.find(row=>row.id===rule.id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {test,expect,type Page} from "@playwright/test"
|
import {test,expect,type Page} from "@playwright/test"
|
||||||
import {emptyAlerts,alertsWithRule} from "./fixtures/alerts"
|
import {emptyAlerts,alertsWithRule} from "./fixtures/alerts"
|
||||||
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,OPTIONS","access-control-allow-headers":"Content-Type,Authorization"}
|
const headers={"access-control-allow-origin":"*","access-control-allow-methods":"GET,PUT,POST,OPTIONS","access-control-allow-headers":"Content-Type,Authorization,If-Match"}
|
||||||
async function source(page:Page,value:string){const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(value);await input.press("Enter");await expect(input).toHaveValue(value);await expect(page.getByText("Доступен",{exact:true})).toBeVisible()}
|
async function source(page:Page,value:string){const input=page.getByRole("textbox",{name:"URL бекенда",exact:true});await input.fill(value);await input.press("Enter");await expect(input).toHaveValue(value);await expect(page.getByText("Доступен",{exact:true})).toBeVisible()}
|
||||||
for(const failed of [false,true])test("Alerts source: ABA discards old primary and target outcomes/"+failed,async({page})=>{
|
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)
|
const startedPaths=new Set<string>();let old=true,started=0,completed=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
||||||
@@ -27,7 +27,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})=>{
|
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;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)
|
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,groups:body.groups,rules:body.rules.map((row:object)=>({...row,lastFiredAt:null}))}})}catch{}finally{completed=true}})
|
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})
|
const toggle=()=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true})
|
||||||
try{await page.goto("/alerts");await expect(toggle()).toBeEnabled();await toggle().click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(toggle()).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(toggle()).toBeChecked();await expect(toggle()).toBeDisabled();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toHaveCount(0);await expect(page.getByText("OLD RULES FAILED",{exact:true})).toHaveCount(0);expect(calls).toBe(1)}finally{release()}
|
try{await page.goto("/alerts");await expect(toggle()).toBeEnabled();await toggle().click();await expect.poll(()=>started).toBe(true);await page.goto("/settings");await source(page,"http://localhost:8101");await page.goto("/alerts");await expect(toggle()).toBeEnabled();await page.goto("/settings");await source(page,"http://localhost:8000");await page.goto("/alerts");await expect(page.getByText(/Результат сохранения правил неизвестен/)).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(toggle()).toBeChecked();await expect(toggle()).toBeDisabled();await expect(page.getByText("Правила и группы сохранены в БД",{exact:true})).toHaveCount(0);await expect(page.getByText("OLD RULES FAILED",{exact:true})).toHaveCount(0);expect(calls).toBe(1)}finally{release()}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {test,expect,type Page} from "@playwright/test"
|
import {test,expect,type Page} from "@playwright/test"
|
||||||
const empty={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
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/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)})
|
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()})
|
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,9 +1,9 @@
|
|||||||
import {test,expect,type Page,type Route} from "@playwright/test"
|
import {test,expect,type Page,type Route} from "@playwright/test"
|
||||||
import {alertsWithRule} from "./fixtures/alerts"
|
import {alertsWithRule,alertsRule} from "./fixtures/alerts"
|
||||||
async function setup(page:Page,write:(route:Route)=>Promise<unknown>,required=false){
|
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/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,groups:body.groups,rules:body.rules.map((rule:object)=>({...rule,lastFiredAt:null}))}}
|
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})
|
const toggle=(page:Page)=>page.getByRole("switch",{name:"Включить правило Fixture rule",exact:true})
|
||||||
test("Rules: pending toggle keeps confirmed state, excludes writes and commits only strict receipt",async({page})=>{
|
test("Rules: pending toggle keeps confirmed state, excludes writes and commits only strict receipt",async({page})=>{
|
||||||
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
let calls=0,release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
|
||||||
@@ -25,3 +25,10 @@ test("Rules: read permission keeps all mutation entry points disabled",async({pa
|
|||||||
test("Rules: unknown edit keeps draft through explicit review and confirmed retry",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:calls===1?{ok:false}:receipt(r)})});await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),input=dialog.getByRole("textbox",{name:"Название правила",exact:true});await input.fill("Changed unknown draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждено");await expect(input).toHaveValue("Changed unknown draft");await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Я проверил правила — разрешить новый запрос",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Changed unknown draft",{exact:true})).toBeVisible();expect(calls).toBe(2)})
|
test("Rules: unknown edit keeps draft through explicit review and confirmed retry",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:calls===1?{ok:false}:receipt(r)})});await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),input=dialog.getByRole("textbox",{name:"Название правила",exact:true});await input.fill("Changed unknown draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toContainText("не подтверждено");await expect(input).toHaveValue("Changed unknown draft");await expect(dialog.getByRole("button",{name:"Сохранить изменения",exact:true})).toBeDisabled();await dialog.getByRole("button",{name:"Я проверил правила — разрешить новый запрос",exact:true}).click();await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Changed unknown draft",{exact:true})).toBeVisible();expect(calls).toBe(2)})
|
||||||
test("Rules presets: no confirmed RTT targets cannot clear existing rules",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("button",{name:"Заменить все",exact:true}).nth(1).click();await page.getByRole("alertdialog").getByRole("button",{name:"Заменить",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toHaveText("Для пресета нет подтверждённых объектов. Список правил сохранён.");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(calls).toBe(0)})
|
test("Rules presets: no confirmed RTT targets cannot clear existing rules",async({page})=>{let calls=0;await setup(page,r=>{calls++;return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("button",{name:"Заменить все",exact:true}).nth(1).click();await page.getByRole("alertdialog").getByRole("button",{name:"Заменить",exact:true}).click();await expect(page.locator("#main-content").getByRole("alert")).toHaveText("Для пресета нет подтверждённых объектов. Список правил сохранён.");await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(calls).toBe(0)})
|
||||||
test("Groups: confirmed delete removes group while retaining its rules without membership",async({page})=>{let payload:{groups:unknown[];rules:{groupId:string|null}[]}|undefined;await setup(page,r=>{payload=r.request().postDataJSON();return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("region",{name:"Группа Fixture group",exact:true}).getByRole("button",{name:"Удалить группу",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("region",{name:"Группа Fixture group",exact:true})).toHaveCount(0);await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(payload?.groups).toEqual([]);expect(payload?.rules).toHaveLength(1);expect(payload?.rules[0]?.groupId).toBeNull()})
|
test("Groups: confirmed delete removes group while retaining its rules without membership",async({page})=>{let payload:{groups:unknown[];rules:{groupId:string|null}[]}|undefined;await setup(page,r=>{payload=r.request().postDataJSON();return r.fulfill({json:receipt(r)})});await page.getByRole("button",{name:/Пресеты и группы/}).click();await page.getByRole("region",{name:"Группа Fixture group",exact:true}).getByRole("button",{name:"Удалить группу",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Удалить",exact:true}).click();await expect(page.getByRole("region",{name:"Группа Fixture group",exact:true})).toHaveCount(0);await expect(page.getByText("Fixture rule",{exact:true})).toBeVisible();expect(payload?.groups).toEqual([]);expect(payload?.rules).toHaveLength(1);expect(payload?.rules[0]?.groupId).toBeNull()})
|
||||||
|
|
||||||
|
test("Rules revision: conflict refresh preserves draft and sends new revision with concurrent rule",async({page})=>{
|
||||||
|
const latest="3".repeat(64),headers:(string|undefined)[]=[];let calls=0,payload:{rules:{id:string}[]}|undefined
|
||||||
|
await setup(page,r=>{calls++;headers.push(r.request().headers()["if-match"]);payload=r.request().postDataJSON();return r.fulfill(calls===1?{status:412,json:{error:"Правила изменились на сервере"}}:{json:receipt(r)})})
|
||||||
|
await page.getByRole("button",{name:"Изменить правило Fixture rule",exact:true}).click();const dialog=page.getByRole("dialog"),input=dialog.getByRole("textbox",{name:"Название правила",exact:true});await input.fill("Conflict draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog.getByRole("alert")).toHaveText("Правила изменились на сервере")
|
||||||
|
await page.route("**/api/alerts",r=>r.fulfill({json:{...alertsWithRule,revision:latest,rules:[alertsRule,{...alertsRule,id:"concurrent-rule",name:"Concurrent rule"}]}}));await dialog.getByRole("button",{name:"Обновить снимок — сохранить черновик",exact:true}).click();await expect(page.getByText("Всего: 2",{exact:true})).toBeVisible();await expect(input).toHaveValue("Conflict draft");await dialog.getByRole("button",{name:"Сохранить изменения",exact:true}).click();await expect(dialog).not.toBeVisible();await expect(page.getByText("Concurrent rule",{exact:true})).toBeVisible();expect(headers).toEqual(['"'+"1".repeat(64)+'"','"'+latest+'"']);expect(payload?.rules.map(rule=>rule.id)).toEqual(["rule-fixture","concurrent-rule"]);expect(calls).toBe(2)
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const emptyAlerts={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
export const emptyAlerts={revision:"1".repeat(64),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
||||||
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 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 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 alertsWithRule={...emptyAlerts,rules:[alertsRule],groups:[alertsGroup],meta:{...emptyAlerts.meta,servers:["router-fixture"],serversDetail:[{name:"router-fixture",host:"192.0.2.1",site:"",country:"",type:""}]}}
|
||||||
Reference in new issue
Block a user