Files
MikrotikManager/backend/src/services/alerts-service.ts
T
DenozordecandCursor ec43591a99
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 4m9s
Docker images / frontend-image (push) Successful in 4m28s
Docker images / updater-image (push) Successful in 58s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
feat(db): перевести хранилище с SQLite на PostgreSQL
При старте backend накатывает схему PostgreSQL 18 и, если база пустая, один раз импортирует mikrotik.db с тома. Повторный старт не копирует данные. Бэкап в UI идёт через pg_dump.

Co-authored-by: Cursor <[email protected]>
2026-09-08 01:36:48 +07:00

585 lines
20 KiB
TypeScript

import { asc, desc, eq, sql } from "drizzle-orm"
import { db, dbAll } from "../db/index.js"
import {
alertGroups,
alertHistory,
alertRuleConditions,
alertRuleTargets,
alertRules,
alertTelegramSettings,
servers,
uptimeProbes,
} from "../db/schema.js"
export type ApiAlertGroup = {
id: string
name: string
combineMode: "any" | "all"
enabled: boolean
cooldownOverride: (typeof alertRules.$inferSelect)["cooldown"] | null
}
export type ApiAlertRule = {
id: string
name: string
type: (typeof alertRules.$inferSelect)["type"]
/** Сводная строка для списка / поиска */
target: string
/** Все объекты правила (OR) */
targets: string[]
groupId: string | null
/** Сводная строка условий для списка */
condition: string
/** Все условия (OR); если пусто в БД — используется `condition` */
conditions: string[]
severity: (typeof alertRules.$inferSelect)["severity"]
enabled: boolean
cooldown: (typeof alertRules.$inferSelect)["cooldown"]
/** Секунды стабильности перед отправкой; null — выключено */
confirmStabilitySec: number | null
/** Политика отправки recovery для этого правила. */
recoveryMode: "always" | "never" | "conditional"
/** Доп. задержка подтверждения recovery при `conditional`. */
recoveryStabilitySec: number | null
chatId: string
lastFiredAt: string | null
}
export type ApiAlertHistoryEntry = {
id: string
ruleName: string
severity: (typeof alertHistory.$inferSelect)["severity"]
message: string
sent: boolean
firedAt: string
source: "db" | "live"
}
async function ensureTelegramRow() {
let row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
if (!row) {
await db.insert(alertTelegramSettings).values({ id: 1 })
row = (await db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1))[0]
}
return row!
}
async function lastFiredByRuleId(): Promise<Map<string, string>> {
const rows = await dbAll<{ ruleId: string; mx: string }>(
`SELECT rule_id AS "ruleId", MAX(fired_at) AS mx
FROM alert_history
WHERE rule_id IS NOT NULL AND rule_id != ''
GROUP BY rule_id`,
)
const m = new Map<string, string>()
for (const r of rows) {
if (r.ruleId && r.mx) m.set(r.ruleId, r.mx)
}
return m
}
async function targetsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
.select()
.from(alertRuleTargets)
.orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id))
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
const arr = m.get(rid) ?? []
arr.push(t.target)
m.set(rid, arr)
}
return m
}
async function conditionsByRuleId(): Promise<Map<string, string[]>> {
const rows = await db
.select()
.from(alertRuleConditions)
.orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id))
const m = new Map<string, string[]>()
for (const t of rows) {
const rid = t.ruleId
const arr = m.get(rid) ?? []
arr.push(t.conditionLine)
m.set(rid, arr)
}
return m
}
export async function listAlertGroups(): Promise<ApiAlertGroup[]> {
const rows = await db
.select()
.from(alertGroups)
.orderBy(asc(alertGroups.name))
return rows.map((g) => ({
id: g.id,
name: g.name,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
}))
}
export async function listAlertRules(): Promise<ApiAlertRule[]> {
const lf = await lastFiredByRuleId()
const tgtMap = await targetsByRuleId()
const condMap = await conditionsByRuleId()
const rows = await db.select().from(alertRules)
return rows.map((r) => {
const targets = tgtMap.get(r.id)
const ts = targets?.length ? targets : [r.target]
const condLines = condMap.get(r.id)
const cs = condLines?.length ? condLines : [r.condition]
return {
id: r.id,
name: r.name,
type: r.type,
target: r.target,
targets: ts,
groupId: r.groupId ?? null,
condition: r.condition,
conditions: cs,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec:
r.confirmStabilitySec != null && r.confirmStabilitySec > 0 ? r.confirmStabilitySec : null,
recoveryMode:
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always",
recoveryStabilitySec:
r.recoveryStabilitySec != null && r.recoveryStabilitySec > 0 ? r.recoveryStabilitySec : null,
chatId: r.chatId ?? "",
lastFiredAt: lf.get(r.id) ?? null,
}
})
}
function summarizeTargets(targets: string[]): string {
const u = targets.map((t) => t.trim()).filter(Boolean)
if (u.length === 0) return ""
if (u.length <= 2) return u.join(", ")
return `${u[0]}, ${u[1]} +${u.length - 2}`
}
function summarizeConditionLines(lines: string[]): string {
const u = lines.map((t) => t.trim()).filter(Boolean)
if (u.length === 0) return "—"
if (u.length === 1) return u[0]!
if (u.length <= 3) return u.join(" · ")
return `${u[0]} · ${u[1]} · +${u.length - 2}`
}
export type ReplaceAlertRuleInput = {
id: string
name: string
type: ApiAlertRule["type"]
/** @deprecated используйте targets; если нет targets — берётся target */
target?: string
targets?: string[]
/** Сводная строка; если не передана — из conditions */
condition?: string
/** Несколько условий (OR); если пусто — используется `condition` */
conditions?: string[]
severity: ApiAlertRule["severity"]
enabled: boolean
cooldown: ApiAlertRule["cooldown"]
/** Секунды; 0 / undefined / null — без задержки подтверждения */
confirmStabilitySec?: number | null
recoveryMode?: "always" | "never" | "conditional"
recoveryStabilitySec?: number | null
chatId: string
groupId?: string | null
}
export type ReplaceAlertGroupInput = {
id: string
name: string
combineMode: "any" | "all"
enabled: boolean
cooldownOverride: ApiAlertRule["cooldown"] | null
}
/** Атомарно заменяет группы, правила и строки targets. */
export async function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
const now = sql`now()`
const { groups, rules } = payload
await db.transaction(async (tx) => {
await tx.delete(alertRuleConditions)
await tx.delete(alertRuleTargets)
await tx.delete(alertRules)
await tx.delete(alertGroups)
for (const g of groups) {
await tx.insert(alertGroups).values({
id: g.id,
name: g.name.trim() || g.id,
combineMode: g.combineMode,
enabled: g.enabled,
cooldownOverride: g.cooldownOverride ?? null,
updatedAt: now,
})
}
for (const r of rules) {
const rawTargets =
r.targets && r.targets.length > 0
? r.targets.map((t) => t.trim()).filter(Boolean)
: [String(r.target ?? "").trim()].filter(Boolean)
const summary = summarizeTargets(rawTargets.length ? rawTargets : ["—"])
const rawConds =
r.conditions && r.conditions.length > 0
? r.conditions.map((c) => c.trim()).filter(Boolean)
: [String(r.condition ?? "").trim()].filter(Boolean)
const condSummary = summarizeConditionLines(rawConds.length ? rawConds : ["—"])
const stab =
r.confirmStabilitySec != null &&
Number.isFinite(r.confirmStabilitySec) &&
r.confirmStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.confirmStabilitySec)))
: null
const recoveryMode =
r.recoveryMode === "never" || r.recoveryMode === "conditional" ? r.recoveryMode : "always"
const recoveryStabilitySec =
r.recoveryStabilitySec != null &&
Number.isFinite(r.recoveryStabilitySec) &&
r.recoveryStabilitySec > 0
? Math.min(86400, Math.max(1, Math.floor(r.recoveryStabilitySec)))
: null
await tx.insert(alertRules).values({
id: r.id,
name: r.name,
type: r.type,
target: summary || "—",
condition: condSummary,
severity: r.severity,
enabled: r.enabled,
cooldown: r.cooldown,
confirmStabilitySec: stab,
recoveryMode,
recoveryStabilitySec,
chatId: r.chatId ?? "",
groupId: r.groupId && r.groupId.trim() ? r.groupId.trim() : null,
updatedAt: now,
})
for (const [i, t] of rawTargets.entries()) {
await tx.insert(alertRuleTargets).values({
id: `rt-${r.id}-${i}`,
ruleId: r.id,
target: t,
sortIndex: i,
})
}
for (const [i, c] of rawConds.entries()) {
await tx.insert(alertRuleConditions).values({
id: `rc-${r.id}-${i}`,
ruleId: r.id,
conditionLine: c,
sortIndex: i,
})
}
}
})
}
/** Совместимость: только правила, группы не трогаем. */
export async function replaceAlertRules(rules: ReplaceAlertRuleInput[]) {
const curGroups = await listAlertGroups()
await replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules })
}
export async function getTelegramSettingsRow() {
return await ensureTelegramRow()
}
export async function getTelegramPublic() {
const row = await ensureTelegramRow()
const tid = row.messageThreadId
const messageThreadId =
typeof tid === "number" && Number.isFinite(tid) && Math.floor(tid) >= 1 ? Math.floor(tid) : null
return {
chatId: row.chatId ?? "",
tokenConfigured: Boolean(row.botToken?.trim()),
messageThreadId,
}
}
export async function updateTelegramSettings(patch: {
token?: string | null
chatId?: string | undefined
/** undefined — не менять; null — сбросить тему */
messageThreadId?: number | null
}) {
const cur = await ensureTelegramRow()
let nextToken = cur.botToken
let nextChat = cur.chatId ?? ""
let nextThread: number | null | undefined = undefined
if (patch.token !== undefined) {
if (patch.token === null || patch.token === "") nextToken = ""
else nextToken = patch.token.trim()
}
if (patch.chatId !== undefined) nextChat = patch.chatId.trim()
if (patch.messageThreadId !== undefined) {
if (patch.messageThreadId === null) nextThread = null
else {
const n = Math.floor(patch.messageThreadId)
nextThread = Number.isFinite(n) && n >= 1 ? n : null
}
}
const base = {
botToken: nextToken,
chatId: nextChat,
updatedAt: sql`now()`,
}
if (nextThread !== undefined) {
await db.update(alertTelegramSettings)
.set({ ...base, messageThreadId: nextThread })
.where(eq(alertTelegramSettings.id, 1))
} else {
await db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1))
}
return await getTelegramPublic()
}
/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */
export async function getTelegramBotToken(): Promise<string> {
return (await ensureTelegramRow()).botToken?.trim() ?? ""
}
/** Для `sendMessage`: только если в БД задана валидная тема. */
export async function getTelegramMessageThreadIdForApi(): Promise<number | undefined> {
const { messageThreadId } = await getTelegramPublic()
return messageThreadId ?? undefined
}
/** Текст тестового сообщения по полям правила (как в UI предпросмотра, без записи в историю). */
export function formatAlertRuleTestTelegramText(opts: {
name: string
targets: string[]
conditionLine: string
severity: "critical" | "warning" | "info"
cooldown: string
}): string {
const emoji =
opts.severity === "critical" ? "🔴" : opts.severity === "warning" ? "🟡" : "🔵"
const obj = opts.targets.length ? opts.targets.join(", ") : "—"
return [
"🧪 MikroTik Manager — тестовая отправка по полям формы правила (в историю срабатываний не записывается).",
"",
`${emoji} ${opts.name}`,
`Объект: ${obj}`,
`Событие: ${opts.conditionLine}`,
`Cooldown: ${opts.cooldown}`,
].join("\n")
}
/** Отправка текста в Telegram (движок алертов и тест). */
export async function sendTelegramAlertMessage(opts: {
text: string
/** Если задан — вместо токена из БД (ручной тест) */
token?: string
/** Пусто — глобальный chat из БД */
chatId?: string
/** Переопределение темы; undefined — как в настройках */
messageThreadId?: number | null
}): Promise<{ ok: true } | { ok: false; error: string }> {
const token = (opts.token?.trim() || await getTelegramBotToken()).trim()
if (!token) return { ok: false, error: "Не задан Bot Token" }
const pub = await getTelegramPublic()
const chatId = (opts.chatId?.trim() || pub.chatId || "").trim()
if (!chatId) return { ok: false, error: "Не задан Chat ID" }
const threadId =
opts.messageThreadId !== undefined && opts.messageThreadId !== null
? opts.messageThreadId >= 1
? Math.floor(opts.messageThreadId)
: undefined
: await getTelegramMessageThreadIdForApi()
const url = `https://api.telegram.org/bot${encodeURIComponent(token)}/sendMessage`
try {
const payload: { chat_id: string; text: string; message_thread_id?: number } = {
chat_id: chatId,
text: opts.text,
}
if (threadId != null) payload.message_thread_id = threadId
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!res.ok || data.ok === false) {
return { ok: false, error: data.description ?? res.statusText ?? "Telegram API error" }
}
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : "Ошибка сети" }
}
}
export async function appendAlertHistory(entry: {
id: string
ruleId?: string | null
groupId?: string | null
ruleName: string
severity: (typeof alertHistory.$inferSelect)["severity"]
message: string
sentOk: boolean
firedAt?: string
}) {
const firedAt = entry.firedAt ?? new Date().toISOString()
await db.insert(alertHistory)
.values({
id: entry.id,
ruleId: entry.ruleId ?? null,
groupId: entry.groupId ?? null,
ruleName: entry.ruleName,
severity: entry.severity,
message: entry.message,
sentOk: entry.sentOk,
firedAt,
})
}
interface LiveResRow {
serverName: string
sampledAt: string
}
interface LiveProbeRow {
probeName: string
target: string
sampledAt: string
status: string
rttMs: number | null
lossPct: number | null
}
async function loadLiveResourceIssues(): Promise<LiveResRow[]> {
return dbAll<LiveResRow>(
`SELECT s.name AS "serverName", urs.sampled_at AS "sampledAt"
FROM uptime_resource_samples urs
INNER JOIN (
SELECT DISTINCT ON (server_id) server_id, sampled_at, status
FROM uptime_resource_samples
ORDER BY server_id, sampled_at DESC
) 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
WHERE urs.status = 'offline'`,
)
}
async function loadLiveProbeIssues(): Promise<LiveProbeRow[]> {
return dbAll<LiveProbeRow>(
`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"
FROM uptime_probe_samples ups
INNER JOIN (
SELECT DISTINCT ON (probe_id) probe_id, sampled_at, status, rtt_ms, loss_pct
FROM uptime_probe_samples
ORDER BY probe_id, sampled_at DESC
) 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
WHERE ups.status IN ('down', 'warn')`,
)
}
async function toLiveHistoryEntries(): Promise<ApiAlertHistoryEntry[]> {
const out: ApiAlertHistoryEntry[] = []
for (const r of await loadLiveResourceIssues()) {
const safeId = `live-res-${r.serverName}-${r.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
out.push({
id: safeId,
ruleName: "Сервер (ресурсы)",
severity: "critical",
message: `${r.serverName}: статус offline по данным мониторинга`,
sent: true,
firedAt: r.sampledAt,
source: "live",
})
}
for (const p of await loadLiveProbeIssues()) {
const sev = p.status === "down" ? "critical" : "warning"
const loss = p.lossPct != null ? `${p.lossPct}%` : "—"
const rtt = p.rttMs != null ? `${p.rttMs} мс` : "—"
const safeId = `live-prb-${p.probeName}-${p.target}-${p.sampledAt}`.replace(/[^a-zA-Z0-9._-]+/g, "_")
out.push({
id: safeId,
ruleName: "Ping-проба",
severity: sev,
message: `${p.probeName}${p.target}: ${p.status} (RTT ${rtt}, потери ${loss})`,
sent: true,
firedAt: p.sampledAt,
source: "live",
})
}
return out
}
export async function listPersistedHistory(limit: number): Promise<ApiAlertHistoryEntry[]> {
const rows = await db
.select()
.from(alertHistory)
.orderBy(desc(alertHistory.firedAt))
.limit(limit)
return rows.map((h) => ({
id: h.id,
ruleName: h.ruleName,
severity: h.severity,
message: h.message,
sent: h.sentOk,
firedAt: h.firedAt,
source: "db" as const,
}))
}
export async function listMergedHistory(persistLimit = 40, liveCap = 25): Promise<ApiAlertHistoryEntry[]> {
const persisted = await listPersistedHistory(persistLimit)
const live = (await toLiveHistoryEntries()).slice(0, liveCap)
const merged = [...persisted, ...live].sort((a, b) => (a.firedAt < b.firedAt ? 1 : a.firedAt > b.firedAt ? -1 : 0))
const seen = new Set<string>()
const dedup: ApiAlertHistoryEntry[] = []
for (const e of merged) {
if (seen.has(e.id)) continue
seen.add(e.id)
dedup.push(e)
if (dedup.length >= 50) break
}
return dedup
}
export async function getAlertsMeta() {
const srvRows = await db
.select({
name: servers.name,
host: servers.host,
site: servers.site,
country: servers.country,
type: servers.type,
})
.from(servers)
const labels = srvRows.map((s) => (s.name?.trim() ? s.name.trim() : s.host))
const serversDetail = srvRows.map((r) => ({
name: r.name?.trim() || "",
host: r.host?.trim() || "",
site: r.site?.trim() || "",
country: r.country?.trim() || "",
type: (r.type ?? "home-router") as string,
}))
const probes = await db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes)
const probeTargets = probes.map((p) => `${p.name}${p.target}`)
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,
}
}