fix(alerts): читать согласованный снимок и реальные цели проб
This commit is contained in:
1 parent
dc5594ca7c
commit
c5c1c2812d
12 files changed
+114
-88
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type {AlertsResponse} from "@mmapp/contracts/alerts"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {can} from "@/lib/auth"
|
import {can} from "@/lib/auth"
|
||||||
import {useAlertsTelegramPreview} from "@/hooks/use-alerts-telegram-preview"
|
import {useAlertsTelegramPreview} from "@/hooks/use-alerts-telegram-preview"
|
||||||
@@ -94,6 +95,7 @@ interface HistoryEntry {
|
|||||||
message: string
|
message: string
|
||||||
time: string
|
time: string
|
||||||
sent: boolean
|
sent: boolean
|
||||||
|
source?:"db"|"live"
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TelegramConfig {
|
interface TelegramConfig {
|
||||||
@@ -131,14 +133,7 @@ interface AlertsApiGroup {
|
|||||||
cooldownOverride: AlertCooldown | null
|
cooldownOverride: AlertCooldown | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertsApiHistoryEntry {
|
type AlertsApiHistoryEntry=AlertsResponse["history"][number]
|
||||||
id: string
|
|
||||||
ruleName: string
|
|
||||||
severity: AlertSeverity
|
|
||||||
message: string
|
|
||||||
sent: boolean
|
|
||||||
firedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertsServerRow {
|
interface AlertsServerRow {
|
||||||
name: string
|
name: string
|
||||||
@@ -259,7 +254,7 @@ function mapApiHistory(rows: AlertsApiHistoryEntry[]): HistoryEntry[] {
|
|||||||
severity: e.severity,
|
severity: e.severity,
|
||||||
message: e.message,
|
message: e.message,
|
||||||
time: formatRelativeRu(e.firedAt),
|
time: formatRelativeRu(e.firedAt),
|
||||||
sent: e.sent,
|
sent: e.sent, source:e.source,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -819,7 +814,7 @@ function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }
|
|||||||
|
|
||||||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||||||
return (
|
return (
|
||||||
<OpsPanel title="Журнал срабатываний" contentClassName="px-4 pb-4">
|
<OpsPanel title="Журнал оповещений" description="Уведомления из БД и текущие проблемы мониторинга." contentClassName="px-4 pb-4">
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -835,11 +830,11 @@ function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
|||||||
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
|
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
|
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
|
||||||
e.sent
|
e.source==="live"||!e.source?"bg-muted text-muted-foreground":e.sent
|
||||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||||
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||||
)}>
|
)}>
|
||||||
{e.sent ? "✓ отправлено" : "✗ ошибка"}
|
{e.source==="live"?"Мониторинг":!e.source?"Демо":e.sent?"API принял":"Не подтверждено"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 ../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-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",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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,telegram:async()=>({chatId:"123",tokenConfigured:true,messageThreadId:null}),groups:async()=>[],rules:async()=>[],history:async()=>[],meta:async()=>({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()=>({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"})
|
||||||
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},...(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)}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ 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
|
let replacements=0,replaceConflict=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,meta:async()=>({servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}),telegram:async()=>({chatId:stored.chatId,tokenConfigured:!!stored.botToken,messageThreadId:stored.messageThreadId}),groups:async()=>[],rules:async()=>[],history:async()=>[],replace:async()=>{replacements++;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?{}:({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 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,[])
|
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)
|
||||||
failSend=true;response=await test();assert.equal(response.statusCode,502);assert.equal(response.json().ok,false);assert.equal(reads,2);assert.equal(sends,2)
|
failSend=true;response=await test();assert.equal(response.statusCode,502);assert.equal(response.json().ok,false);assert.equal(reads,2);assert.equal(sends,2)
|
||||||
for(const payload of [{messageThreadId:0},{messageThreadId:1.2},{messageThreadId:true},{messageThreadId:"1"},{messageThreadId:2147483648},{unknown:true}]){response=await app.inject({method:"POST",url:"/api/alerts/telegram/test",payload});assert.equal(response.statusCode,400)}assert.equal(sends,2);assert.equal(reads,2)
|
for(const payload of [{messageThreadId:0},{messageThreadId:1.2},{messageThreadId:true},{messageThreadId:"1"},{messageThreadId:2147483648},{unknown:true}]){response=await app.inject({method:"POST",url:"/api/alerts/telegram/test",payload});assert.equal(response.statusCode,400)}assert.equal(sends,2);assert.equal(reads,2)
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import {sendTelegramRequest} from "../services/alerts-telegram-send.js"
|
import {sendTelegramRequest} from "../services/alerts-telegram-send.js"
|
||||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||||
import {
|
import {
|
||||||
getAlertsMeta,
|
getAlertsOverview,
|
||||||
getTelegramPublic,
|
|
||||||
getTelegramSettingsRow,
|
getTelegramSettingsRow,
|
||||||
listAlertGroups,
|
|
||||||
listAlertRules,
|
|
||||||
listMergedHistory,
|
|
||||||
replaceAlertsConfig,
|
replaceAlertsConfig,
|
||||||
formatAlertRuleTestTelegramText,
|
formatAlertRuleTestTelegramText,
|
||||||
updateTelegramSettings,
|
updateTelegramSettings,
|
||||||
} from "../services/alerts-service.js"
|
} from "../services/alerts-service.js"
|
||||||
import {
|
import {
|
||||||
|
alertsResponseSchema,
|
||||||
putRulesSchema,
|
putRulesSchema,
|
||||||
normalizeAlertsConfig,
|
normalizeAlertsConfig,
|
||||||
putTelegramSchema,
|
putTelegramSchema,
|
||||||
@@ -20,21 +17,15 @@ import {
|
|||||||
} from "@mmapp/contracts/alerts"
|
} from "@mmapp/contracts/alerts"
|
||||||
|
|
||||||
interface Dependencies {
|
interface Dependencies {
|
||||||
meta:typeof getAlertsMeta;telegram:typeof getTelegramPublic;groups:typeof listAlertGroups;rules:typeof listAlertRules;history:typeof listMergedHistory;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings
|
overview:()=>Promise<unknown>;replace:typeof replaceAlertsConfig;writeTelegram:typeof updateTelegramSettings
|
||||||
readTelegram:()=>Promise<{botToken:string;chatId:string;messageThreadId:number|null}>
|
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}>
|
send:(input:{token:string;chatId:string;text:string;messageThreadId?:number})=>Promise<{ok:true}|{ok:false;error:string}>
|
||||||
}
|
}
|
||||||
export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {return async(app)=>{
|
export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {return async(app)=>{
|
||||||
app.get("/alerts", async (_req, reply) => {
|
app.get("/alerts", async (_req, reply) => {
|
||||||
const tg = await deps.telegram()
|
const result=alertsResponseSchema.safeParse(await deps.overview())
|
||||||
const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim())
|
if(!result.success)return reply.status(500).send({error:"Снимок оповещений не подтверждён"})
|
||||||
return reply.send({
|
return reply.header("Cache-Control","no-store").send(result.data)
|
||||||
telegram: { ...tg, connected },
|
|
||||||
groups: await deps.groups(),
|
|
||||||
rules: await deps.rules(),
|
|
||||||
history: await deps.history(),
|
|
||||||
meta: await deps.meta(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.put("/alerts/rules", async (req, reply) => {
|
app.put("/alerts/rules", async (req, reply) => {
|
||||||
@@ -116,5 +107,5 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
export const defaultAlertsDependencies:Dependencies={meta:getAlertsMeta,telegram:getTelegramPublic,groups:listAlertGroups,rules:listAlertRules,history:listMergedHistory,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={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)
|
export default createAlertsRoutes(defaultAlertsDependencies)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {test} from "node:test"
|
||||||
|
import type {AlertsResponse} from "@mmapp/contracts/alerts"
|
||||||
|
import {readAlertsOverview,alertsOverviewTransaction,buildAlertsMeta} from "./alerts-overview.js"
|
||||||
|
const empty:AlertsResponse={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()=>{
|
||||||
|
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 deps={transaction:async(work:(value:typeof context)=>Promise<typeof empty>,options:typeof alertsOverviewTransaction)=>{transactions++;assert.deepEqual(options,{isolationLevel:"repeatable read",accessMode:"read only"});return work(context)},telegram:read("telegram"),groups:read("groups"),rules:read("rules"),history:read("history"),meta:read("meta")}
|
||||||
|
assert.deepEqual(await readAlertsOverview(deps),empty);assert.equal(transactions,1);assert.deepEqual(calls,["telegram","groups","rules","history","meta"])
|
||||||
|
await assert.rejects(readAlertsOverview({...deps,rules:async()=>null}),/Снимок оповещений не подтверждён/)
|
||||||
|
await assert.rejects(readAlertsOverview({...deps,history:async()=>{throw Error("DB read failed")}}),/DB read failed/)
|
||||||
|
})
|
||||||
|
test("Alerts metadata exposes existing probe keys without synthesizing per-server targets",()=>{
|
||||||
|
const servers=[{name:"router-fixture",host:"192.0.2.1",site:null,country:null,type:null}]
|
||||||
|
const withoutProbes=buildAlertsMeta(servers,[]);assert.deepEqual(withoutProbes.probeTargets,[]);assert.deepEqual(withoutProbes.rttLossTargets,[]);assert.equal(withoutProbes.serversDetail[0]?.type,"")
|
||||||
|
const actual=buildAlertsMeta(servers,[{name:"probe-fixture",target:"1.1.1.1"}]);assert.deepEqual(actual.probeTargets,["probe-fixture → 1.1.1.1"]);assert.deepEqual(actual.rttLossTargets,actual.probeTargets);assert.equal(JSON.stringify(actual).includes("8.8.8.8"),false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {alertsResponseSchema,type AlertsResponse} from "@mmapp/contracts/alerts"
|
||||||
|
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>}
|
||||||
|
export async function readAlertsOverview<C>(deps:Dependencies<C>):Promise<AlertsResponse>{
|
||||||
|
return deps.transaction(async 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)})
|
||||||
|
if(!parsed.success)throw Error("Снимок оповещений не подтверждён")
|
||||||
|
return parsed.data
|
||||||
|
},alertsOverviewTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAlertsMeta(servers:{name:string|null;host:string;site:string|null;country:string|null;type:string|null}[],probes:{name:string;target:string}[]){
|
||||||
|
const labels=servers.map(server=>server.name?.trim()||server.host)
|
||||||
|
const probeTargets=probes.map(probe=>probe.name+" → "+probe.target)
|
||||||
|
return {servers:labels,greClients:labels,probeTargets,rttLossTargets:probeTargets,trafficServers:labels,serversDetail:servers.map(server=>({name:server.name?.trim()||"",host:server.host.trim(),site:server.site?.trim()||"",country:server.country?.trim()||"",type:server.type??""}))}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
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"
|
||||||
import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js"
|
import {executeTelegramSettingsUpdate} from "./alerts-telegram-settings.js"
|
||||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||||
import { db, dbAll } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import {
|
import {
|
||||||
alertGroups,
|
alertGroups,
|
||||||
alertHistory,
|
alertHistory,
|
||||||
@@ -297,14 +298,12 @@ export async function getTelegramSettingsRow() {
|
|||||||
return await ensureTelegramRow()
|
return await ensureTelegramRow()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTelegramPublic() {
|
export async function getTelegramPublic(context:AlertsReadContext=db) {
|
||||||
const row = await ensureTelegramRow()
|
const row = (await context.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id,1)).limit(1))[0]
|
||||||
const tid = row.messageThreadId
|
const messageThreadId = row?.messageThreadId ?? null
|
||||||
const messageThreadId =
|
|
||||||
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
|
|
||||||
return {
|
return {
|
||||||
chatId: row.chatId ?? "",
|
chatId: row?.chatId ?? "",
|
||||||
tokenConfigured: Boolean(row.botToken?.trim()),
|
tokenConfigured: Boolean(row?.botToken?.trim()),
|
||||||
messageThreadId,
|
messageThreadId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,9 +422,8 @@ interface LiveProbeRow {
|
|||||||
lossPct: number | null
|
lossPct: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
|
async function loadLiveResourceIssues(context:AlertsReadContext): Promise<LiveResRow[]> {
|
||||||
return dbAll<LiveResRow>(
|
const result=await context.execute(sql.raw(`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
|
||||||
`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
|
|
||||||
FROM uptime_resource_samples urs
|
FROM uptime_resource_samples urs
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
|
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
|
||||||
@@ -433,13 +431,12 @@ async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
|
|||||||
ORDER BY server_id, sampled_at DESC
|
ORDER BY server_id, sampled_at DESC
|
||||||
) latest ON latest.server_id = urs.server_id AND latest.sampled_at = urs.sampled_at
|
) latest ON latest.server_id = urs.server_id AND latest.sampled_at = urs.sampled_at
|
||||||
INNER JOIN servers s ON s.id = urs.server_id
|
INNER JOIN servers s ON s.id = urs.server_id
|
||||||
WHERE urs.status = 'offline'`,
|
WHERE urs.status = 'offline'`))
|
||||||
)
|
return result.rows as unknown as LiveResRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
|
async function loadLiveProbeIssues(context:AlertsReadContext): Promise<LiveProbeRow[]> {
|
||||||
return dbAll<LiveProbeRow>(
|
const result=await context.execute(sql.raw(`SELECT p.name AS "probeName", p.target AS target, ups.sampled_at AS "sampledAt",
|
||||||
`SELECT p.name AS "probeName", p.target AS target, ups.sampled_at AS "sampledAt",
|
|
||||||
ups.status AS status, ups.rtt_ms AS "rttMs", ups.loss_pct AS "lossPct"
|
ups.status AS status, ups.rtt_ms AS "rttMs", ups.loss_pct AS "lossPct"
|
||||||
FROM uptime_probe_samples ups
|
FROM uptime_probe_samples ups
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
@@ -448,25 +445,25 @@ async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
|
|||||||
ORDER BY probe_id, sampled_at DESC
|
ORDER BY probe_id, sampled_at DESC
|
||||||
) latest ON latest.probe_id = ups.probe_id AND latest.sampled_at = ups.sampled_at
|
) latest ON latest.probe_id = ups.probe_id AND latest.sampled_at = ups.sampled_at
|
||||||
INNER JOIN uptime_probes p ON p.id = ups.probe_id
|
INNER JOIN uptime_probes p ON p.id = ups.probe_id
|
||||||
WHERE ups.status IN ('down', 'warn')`,
|
WHERE ups.status IN ('down', 'warn')`))
|
||||||
)
|
return result.rows as unknown as LiveProbeRow[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
|
async function toLiveHistoryEntries(context:AlertsReadContext): Promise<ApiAlertHistoryEntry[]> {
|
||||||
const out: ApiAlertHistoryEntry[] = []
|
const out: ApiAlertHistoryEntry[] = []
|
||||||
for (const r of await loadLiveResourceIssues()) {
|
for (const r of await loadLiveResourceIssues(context)) {
|
||||||
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||||
out.push({
|
out.push({
|
||||||
id: safeId,
|
id: safeId,
|
||||||
ruleName: "Сервер (ресурсы)",
|
ruleName: "Сервер (ресурсы)",
|
||||||
severity: "critical",
|
severity: "critical",
|
||||||
message: `${r.serverName}: статус offline по данным мониторинга`,
|
message: `${r.serverName}: статус offline по данным мониторинга`,
|
||||||
sent: true,
|
sent: false,
|
||||||
firedAt: r.sampledAt,
|
firedAt: r.sampledAt,
|
||||||
source: "live",
|
source: "live",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const p of await loadLiveProbeIssues()) {
|
for (const p of await loadLiveProbeIssues(context)) {
|
||||||
const sev = p.status === "down" ? "critical" : "warning"
|
const sev = p.status === "down" ? "critical" : "warning"
|
||||||
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
|
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
|
||||||
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
|
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
|
||||||
@@ -476,7 +473,7 @@ async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
|
|||||||
ruleName: "Ping-проба",
|
ruleName: "Ping-проба",
|
||||||
severity: sev,
|
severity: sev,
|
||||||
message: `${p.probeName} → ${p.target}: ${p.status} (RTT ${rtt}, потери ${loss})`,
|
message: `${p.probeName} → ${p.target}: ${p.status} (RTT ${rtt}, потери ${loss})`,
|
||||||
sent: true,
|
sent: false,
|
||||||
firedAt: p.sampledAt,
|
firedAt: p.sampledAt,
|
||||||
source: "live",
|
source: "live",
|
||||||
})
|
})
|
||||||
@@ -484,8 +481,8 @@ async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPersistedHistory(limit: number): Promise<ApiAlertHistoryEntry[]> {
|
export async function listPersistedHistory(limit: number,context:AlertsReadContext=db): Promise<ApiAlertHistoryEntry[]> {
|
||||||
const rows = await db
|
const rows = await context
|
||||||
.select()
|
.select()
|
||||||
.from(alertHistory)
|
.from(alertHistory)
|
||||||
.orderBy(desc(alertHistory.firedAt))
|
.orderBy(desc(alertHistory.firedAt))
|
||||||
@@ -501,9 +498,9 @@ export async function listPersistedHistory(limit: number): Promise<ApiAlertHisto
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promise<ApiAlertHistoryEntry[]> {
|
export async function listMergedHistory(persistLimit = 40, liveCap = 25,context:AlertsReadContext=db): Promise<ApiAlertHistoryEntry[]> {
|
||||||
const persisted = await listPersistedHistory(persistLimit)
|
const persisted = await listPersistedHistory(persistLimit,context)
|
||||||
const live = (await toLiveHistoryEntries()).slice(0, liveCap)
|
const live = (await toLiveHistoryEntries(context)).slice(0, liveCap)
|
||||||
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
|
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const dedup: ApiAlertHistoryEntry[] = []
|
const dedup: ApiAlertHistoryEntry[] = []
|
||||||
@@ -516,8 +513,8 @@ export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promis
|
|||||||
return dedup
|
return dedup
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAlertsMeta() {
|
export async function getAlertsMeta(context:AlertsReadContext=db) {
|
||||||
const srvRows = await db
|
const srvRows = await context
|
||||||
.select({
|
.select({
|
||||||
name: servers.name,
|
name: servers.name,
|
||||||
host: servers.host,
|
host: servers.host,
|
||||||
@@ -526,27 +523,15 @@ export async function getAlertsMeta() {
|
|||||||
type: servers.type,
|
type: servers.type,
|
||||||
})
|
})
|
||||||
.from(servers)
|
.from(servers)
|
||||||
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
|
const probes=await context.select({name:uptimeProbes.name,target:uptimeProbes.target}).from(uptimeProbes)
|
||||||
const serversDetail = srvRows.map((r) => ({
|
return buildAlertsMeta(srvRows,probes)
|
||||||
name: r.name?.trim() || "",
|
}
|
||||||
host: r.host?.trim() || "",
|
|
||||||
site: r.site?.trim() || "",
|
export async function getAlertsOverview(){
|
||||||
country: r.country?.trim() || "",
|
return readAlertsOverview<AlertsWriteContext>({
|
||||||
type: (r.type ?? "home-router") as string,
|
transaction:(work,options)=>db.transaction(work,options),
|
||||||
}))
|
telegram:async context=>{const value=await getTelegramPublic(context);return {...value,connected:value.tokenConfigured&&Boolean(value.chatId.trim())}},
|
||||||
const probes = await db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes)
|
groups:context=>listAlertGroups(context),rules:context=>listAlertRules(context),
|
||||||
const probeTargets = probes.map((p) => `${p.name} → ${p.target}`)
|
history:context=>listMergedHistory(40,25,context),meta:context=>getAlertsMeta(context),
|
||||||
const defaults = ["8.8.8.8", "1.1.1.1"]
|
})
|
||||||
const rttLossTargets: string[] = []
|
|
||||||
for (const L of labels.slice(0, 12)) {
|
|
||||||
for (const d of defaults) rttLossTargets.push(`${L} → ${d}`)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
servers: labels,
|
|
||||||
greClients: labels,
|
|
||||||
probeTargets: probeTargets.length ? probeTargets : rttLossTargets,
|
|
||||||
rttLossTargets: rttLossTargets.length ? rttLossTargets : labels.map((L) => `${L} → 8.8.8.8`),
|
|
||||||
trafficServers: labels,
|
|
||||||
serversDetail,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 2026-10-03
|
||||||
|
|
||||||
Этап 11Б в работе; последний проверенный блок 11Б.4.3.3.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.4.1: согласованный read-only GET Alerts и достоверные metadata/history; затем optimistic revision/outbox audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
Этап 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.
|
||||||
|
|
||||||
Реальные 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.3.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.4.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 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 не округляется молча.
|
||||||
|
- Строгий overview проверяется внутри read transaction и повторно в HTTP; malformed response не подтверждается, Cache-Control:no-store. DI проверяет один context/options/ошибки, HTTP/JWT fixture обновлены на единую overview dependency. Реальная PG isolation/parallel write не доказана локальными DI.
|
||||||
|
- Metadata probeTargets/rttLossTargets содержит только существующие uptimeProbes. Убраны выдуманные ping-цели сервер→8.8.8.8/1.1.1.1 и выдуманная роль при missing type; legacy gre-client server labels оставлены по текущей evalServerLike политике. Actual RouterOS/probe operation не запускалась.
|
||||||
|
- Synthetic live history имеет sent:false и сохраняет source. UI отделяет «Мониторинг», «API принял» и «Не подтверждено»; source:live даже legacy sent:true не утверждает отправку, DB sent:false не выдается за доказанную ошибку. Демо помечено отдельно. Журнал описывает уведомления и текущие проблемы.
|
||||||
|
- 2 overview/meta DI + профильный Alerts backend suite/HTTP/JWT, 1 history browser, backend build, root typecheck без incremental/lint пройдены. Первый root typecheck поймал отсутствующий source в ручном DTO; заменён на тип contracts, повтор прошёл. Не выполнена live PG проверка; optimistic revision и outbox ещё впереди.
|
||||||
|
- Следующий точный шаг 11Б.4.4.2: optimistic revision/If-Match правил и групп, чтобы старый клиент не перезаписывал новый snapshot; затем outbox unknown audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||||
|
|
||||||
## Блок 11Б.4.3.3.1 — общий guard Telegram preview (2026-10-03)
|
## Блок 11Б.4.3.3.1 — общий guard Telegram preview (2026-10-03)
|
||||||
|
|
||||||
- Rule preview снова доступен: strict testAlertsTelegram, caller abort/source lifetime, synchronous lock со всеми Alerts mutations, alerts:write/live/health/known token. Отправляется snapshot черновика без записи rule и без токена из сохранённых параметров в body.
|
- Rule preview снова доступен: strict testAlertsTelegram, caller abort/source lifetime, synchronous lock со всеми Alerts mutations, alerts:write/live/health/known token. Отправляется snapshot черновика без записи rule и без токена из сохранённых параметров в body.
|
||||||
|
|||||||
@@ -268,3 +268,8 @@ Strict receipt вместо optimistic overwrite, draft editor/group формы,
|
|||||||
### 11Б.4.3.3.1 — Telegram preview (2026-10-03)
|
### 11Б.4.3.3.1 — Telegram preview (2026-10-03)
|
||||||
|
|
||||||
Strict preview и общий checkpoint карточки/editor без секретов, common lock/source/rights, inline API acceptance. 3 browser + 2 source, Telegram regression, typecheck/lint прошли. Доставка и реальные PG/Telegram не приняты. Далее 11Б.4.4.1: согласованный read-only GET Alerts и достоверные metadata/history; затем optimistic revision/outbox audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
Strict preview и общий checkpoint карточки/editor без секретов, common lock/source/rights, inline API acceptance. 3 browser + 2 source, Telegram regression, typecheck/lint прошли. Доставка и реальные PG/Telegram не приняты. Далее 11Б.4.4.1: согласованный read-only GET Alerts и достоверные metadata/history; затем optimistic revision/outbox audit, общий dashboard permission reader, адаптивность/pickers, Releases/probe scheduling и приёмка 11Б/12.
|
||||||
|
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user