Files
MikrotikManager/packages/contracts/src/alerts.ts
T

141 lines
9.4 KiB
TypeScript

import { z } from "zod"
export const alertTypeSchema = z.enum([
"gre-tunnel",
"bgp-peer",
"bgp-prefix",
"gre-client",
"server",
"rtt",
"loss",
"traffic",
])
export const alertSeveritySchema = z.enum(["critical", "warning", "info"])
export const alertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"])
export const recoveryModeSchema = z.enum(["always", "never", "conditional"])
export const combineModeSchema = z.enum(["any", "all"])
export const alertGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
combineMode: combineModeSchema,
enabled: z.boolean(),
cooldownOverride: z.union([alertCooldownSchema, z.null()]),
})
export const alertRuleSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
type: alertTypeSchema,
target: z.string().optional(),
targets: z.array(z.string().min(1)).optional(),
groupId: z.string().nullable().optional(),
condition: z.string().optional(),
conditions: z.array(z.string().min(1)).optional(),
severity: alertSeveritySchema,
enabled: z.boolean(),
cooldown: alertCooldownSchema,
confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
recoveryMode: recoveryModeSchema.optional(),
recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
chatId: z.string(),
})
.refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), {
message: "Нужен хотя бы один объект: targets или target",
path: ["targets"],
})
.refine((r) => (r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()), {
message: "Нужно хотя бы одно условие: conditions или condition",
path: ["conditions"],
})
export const putRulesSchema = z.object({
rules: z.array(alertRuleSchema),
groups: z.array(alertGroupSchema).optional(),
})
export const putTelegramSchema = z.object({
token: z.union([z.string(), z.null()]).optional(),
chatId: z.string().optional(),
messageThreadId: z.union([z.number().int().positive().max(2147483647), z.null()]).optional(),
})
export const rulePreviewTelegramSchema = z.object({
name: z.string().min(1),
targets: z.array(z.string().min(1)).min(1),
conditionLine: z.string().min(1),
severity: alertSeveritySchema,
cooldown: alertCooldownSchema,
})
export const testTelegramSchema = z.object({
token: z.string().optional(),
chatId: z.string().optional(),
messageThreadId: z.number().int().positive().max(2147483647).optional(),
rulePreview: rulePreviewTelegramSchema.optional(),
})
export type AlertGroup = z.infer<typeof alertGroupSchema>
export type AlertRule = z.infer<typeof alertRuleSchema>
export type PutRulesBody = z.infer<typeof putRulesSchema>
export type PutTelegramBody = z.infer<typeof putTelegramSchema>
export type RulePreviewTelegram = z.infer<typeof rulePreviewTelegramSchema>
export type TestTelegramBody = z.infer<typeof testTelegramSchema>
// Public response schemas: configured credentials do not prove Telegram connectivity/delivery.
const alertTimestampSchema = z.string().min(1).refine(value => Number.isFinite(Date.parse(value)), "Некорректное время оповещения")
export const telegramPublicSchema = z.object({
chatId:z.string(),tokenConfigured:z.boolean(),connected:z.boolean(),messageThreadId:z.number().int().positive().max(2147483647).nullable(),
}).strict().refine(value => value.connected === (value.tokenConfigured && Boolean(value.chatId.trim())), "Противоречивое состояние параметров Telegram")
export const alertRuleResponseSchema = z.object({
id:z.string().min(1),name:z.string().min(1),type:alertTypeSchema,target:z.string(),targets:z.array(z.string().min(1)),groupId:z.string().nullable(),condition:z.string(),conditions:z.array(z.string().min(1)),severity:alertSeveritySchema,enabled:z.boolean(),cooldown:alertCooldownSchema,
confirmStabilitySec:z.number().int().min(0).max(86400).nullable(),recoveryMode:recoveryModeSchema,recoveryStabilitySec:z.number().int().min(0).max(86400).nullable(),chatId:z.string(),lastFiredAt:alertTimestampSchema.nullable(),
}).strict()
export const alertHistoryResponseSchema = z.object({id:z.string().min(1),ruleName:z.string(),severity:alertSeveritySchema,message:z.string(),sent:z.boolean(),firedAt:alertTimestampSchema,source:z.enum(["db","live"])}).strict()
export const alertsMetaResponseSchema = z.object({
servers:z.array(z.string()),greClients:z.array(z.string()),probeTargets:z.array(z.string()),rttLossTargets:z.array(z.string()),trafficServers:z.array(z.string()),
serversDetail:z.array(z.object({name:z.string(),host:z.string(),site:z.string(),country:z.string(),type:z.string()}).strict()),
}).strict()
const alertsConfigResponseShape = {groups:z.array(alertGroupSchema.strict()),rules:z.array(alertRuleResponseSchema)}
function validConfig(value:{groups:{id:string}[];rules:{id:string;groupId:string|null}[]}):boolean {
const groups=new Set(value.groups.map(row=>row.id)),rules=new Set(value.rules.map(row=>row.id))
return groups.size===value.groups.length&&rules.size===value.rules.length&&value.rules.every(row=>row.groupId===null||groups.has(row.groupId))
}
export const alertsRevisionSchema=z.string().regex(/^[a-f0-9]{64}$/)
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 telegramTestReceiptSchema = z.object({ok:z.literal(true)}).strict()
export type AlertsResponse = z.output<typeof alertsResponseSchema>
/** Normalize the stored rule representation without altering OR/AND or cooldown policies. */
export function normalizeAlertsConfig(input:PutRulesBody){
const parsed=putRulesSchema.strict().safeParse(input)
if(!parsed.success)throw Error("Проверьте правила и группы оповещений")
const groups=parsed.data.groups?.map(group=>({...group,name:group.name.trim()||group.id}))
const groupIds=groups===undefined?undefined:new Set(groups.map(group=>group.id))
const ids=new Set<string>()
if(groups!==undefined&&groupIds!.size!==groups.length)throw Error("Повторяющиеся ID групп")
const rules=parsed.data.rules.map(rule=>{
if(ids.has(rule.id))throw Error("Повторяющиеся ID правил")
ids.add(rule.id)
const targets=(rule.targets?.length?rule.targets:[rule.target??""]).map(value=>value.trim()).filter(Boolean)
const conditions=(rule.conditions?.length?rule.conditions:[rule.condition??""]).map(value=>value.trim()).filter(Boolean)
if(!targets.length||!conditions.length)throw Error("Нужны непустые объекты и условия правила")
const groupId=rule.groupId?.trim()||null
if(groupId!==null&&groupIds!==undefined&&!groupIds.has(groupId))throw Error("Неизвестная группа правила")
const target=targets.length<=2?targets.join(", "):targets[0]+", "+targets[1]+" +"+(targets.length-2)
const condition=conditions.length===1?conditions[0]!:conditions.length<=3?conditions.join(" · "):conditions[0]+" · "+conditions[1]+" · +"+(conditions.length-2)
return {...rule,target,targets,condition,conditions,groupId,confirmStabilitySec:rule.confirmStabilitySec||null,recoveryMode:rule.recoveryMode??"always",recoveryStabilitySec:rule.recoveryStabilitySec||null}
})
return {...(groups!==undefined?{groups}:{}),rules}
}
const outboxCount=z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
export const alertsOutboxOverviewSchema=z.object({counts:z.object({total:outboxCount,pending:outboxCount,processing:outboxCount,unknown:outboxCount,sent:outboxCount,failed:outboxCount,unrecognized:outboxCount}).strict(),items:z.array(z.object({id:z.string().min(1),resolutionVersion:z.number().int().nonnegative().max(2147483647),status:z.enum(["processing","unknown"]),ruleName:z.string().nullable(),createdAt:alertTimestampSchema,attemptedAt:alertTimestampSchema}).strict()).max(20),itemsLimit:z.literal(20)}).strict().refine(value=>value.counts.total===value.counts.pending+value.counts.processing+value.counts.unknown+value.counts.sent+value.counts.failed+value.counts.unrecognized&&value.items.length===Math.min(20,value.counts.processing+value.counts.unknown)&&value.items.filter(item=>item.status==="processing").length<=value.counts.processing&&value.items.filter(item=>item.status==="unknown").length<=value.counts.unknown&&new Set(value.items.map(item=>item.id)).size===value.items.length,"Неполное состояние очереди")
export type AlertsOutboxOverview=z.output<typeof alertsOutboxOverviewSchema>
export const alertsOutboxRetrySchema=z.object({expectedVersion:z.number().int().nonnegative().max(2147483646),acknowledgedDuplicateRisk:z.literal(true)}).strict()
export const alertsOutboxRetryReceiptSchema=z.object({ok:z.literal(true),id:z.string().min(1),status:z.literal("pending"),resolutionVersion:z.number().int().min(1).max(2147483647)}).strict()