diff --git a/.cursor/rules/dev-run-command.mdc b/.cursor/rules/dev-run-command.mdc new file mode 100644 index 0000000..a79e0bf --- /dev/null +++ b/.cursor/rules/dev-run-command.mdc @@ -0,0 +1,19 @@ +--- +description: "Быстрый запуск локального frontend и backend" +alwaysApply: true +--- + +# Локальный запуск проекта + +Для запуска всего проекта в dev-режиме поднимать два процесса: + +```powershell +npm run dev +npm --prefix backend run dev +``` + +- Frontend: `http://localhost:3000` +- Backend: `http://localhost:8000` +- Health check backend: `http://localhost:8000/health` + +Если пользователь просит “запусти проект”, “запусти фронт и бэк” или похожую команду, сначала проверь уже запущенные терминалы, затем запускай эти две команды отдельными long-running процессами. diff --git a/.gitignore b/.gitignore index d81dc02..acb0034 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,50 @@ -node_modules -.next -dist -build -coverage -*.tsbuildinfo +# dependencies +node_modules/ + +# next.js +.next/ +out/ + +# production +build/ +dist/ + +# turbo +.turbo/ + +# logs +*.log npm-debug.log* yarn-debug.log* -yarn-error.log* pnpm-debug.log* + +# env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# cache +.cache/ +.parcel-cache/ + +# coverage +coverage/ + +# typescript +*.tsbuildinfo +next-env.d.ts + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# misc +.vercel/ +tmp/ +temp/ diff --git a/app/(main)/alerts/page.tsx b/app/(main)/alerts/page.tsx index 9e4364e..cb24415 100644 --- a/app/(main)/alerts/page.tsx +++ b/app/(main)/alerts/page.tsx @@ -1,20 +1,32 @@ "use client" -import { useState, useMemo } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useDataSource } from "@/lib/data-source" import { PageHeader } from "@/components/page-header" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { Separator } from "@/components/ui/separator" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter, } from "@/components/ui/sheet" import { - BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, + BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon, CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon, TimerIcon, WifiOffIcon, TrendingDownIcon, EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon, Trash2Icon, + PencilIcon, + ChevronsUpDownIcon, + SearchIcon, + AlertCircleIcon, + ChevronUpIcon, + ChevronDownIcon, + PlugIcon, + UnplugIcon, } from "lucide-react" +import { Flag, countryName } from "@/components/flag" import { cn } from "@/lib/utils" // ─── types ──────────────────────────────────────────────────────────────────── @@ -22,20 +34,40 @@ import { cn } from "@/lib/utils" type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic" type AlertSeverity = "critical" | "warning" | "info" type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч" +type RecoveryMode = "always" | "never" | "conditional" interface AlertRule { id: string name: string type: AlertType - target: string // display label - condition: string // display label + /** Сводная строка (как с бэкенда) */ + target: string + /** Объекты правила (OR); если пусто — используется `target` */ + targets: string[] + groupId: string | null + /** Сводная подпись условий в списке */ + condition: string + /** Все условия (OR); при отсутствии — трактуем как `[condition]` */ + conditions: string[] severity: AlertSeverity enabled: boolean cooldown: AlertCooldown + /** Секунды: уведомление только если условие не пропало всё это время; null — выкл. */ + confirmStabilitySec: number | null + recoveryMode?: RecoveryMode + recoveryStabilitySec?: number | null lastFired: string | null // relative time string chatId: string // override; empty = use global } +interface AlertGroup { + id: string + name: string + combineMode: "any" | "all" + enabled: boolean + cooldownOverride: AlertCooldown | null +} + interface HistoryEntry { id: string ruleName: string @@ -48,9 +80,375 @@ interface HistoryEntry { interface TelegramConfig { token: string chatId: string + /** Положительное число — тема супергруппы (forum), Bot API: `message_thread_id` */ + messageThreadId: string connected: boolean } +interface AlertsApiRule { + id: string + name: string + type: AlertType + target: string + targets?: string[] + groupId?: string | null + condition: string + conditions?: string[] + severity: AlertSeverity + enabled: boolean + cooldown: AlertCooldown + confirmStabilitySec?: number | null + recoveryMode?: RecoveryMode + recoveryStabilitySec?: number | null + chatId: string + lastFiredAt: string | null +} + +interface AlertsApiGroup { + id: string + name: string + combineMode: "any" | "all" + enabled: boolean + cooldownOverride: AlertCooldown | null +} + +interface AlertsApiHistoryEntry { + id: string + ruleName: string + severity: AlertSeverity + message: string + sent: boolean + firedAt: string +} + +interface AlertsServerRow { + name: string + host: string + site: string + country: string + type: string +} + +/** Каталог GRE для карточек объекта; `targetLabel` = значение `target` в правиле. */ +interface AlertsGreTunnelRow { + targetLabel: string + tunnelName: string + routerName: string + country: string + site: string + remoteAddress: string + status: "up" | "down" | "degraded" | string + comment?: string +} + +interface AlertsMeta { + servers: string[] + greClients: string[] + probeTargets: string[] + rttLossTargets: string[] + trafficServers: string[] + /** Каталог для карточек «Объект» (сервер / GRE-клиент / трафик) */ + serversDetail?: AlertsServerRow[] + /** Собирается на клиенте из `/api/bgp/sessions` в live-режиме */ + bgpPeers?: string[] + /** Подписи «имя туннеля / сервер» из `/api/filters/gre-tunnels` + `/api/servers` (как на /gre) */ + greTunnelTargets?: string[] + /** Расширенные поля для карточек выбора туннеля */ + greTunnelDetail?: AlertsGreTunnelRow[] +} + +/** Минимальный ответ BGP для подписей «роутер · сосед». */ +interface BgpSessionListRow { + serverName: string + name: string +} + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} + const res = await fetch(backendUrl.replace(/\/$/, "") + path, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) as { error?: string } + throw new Error(err.error ?? res.statusText) + } + if (res.status === 204) return undefined as T + return res.json() as Promise + } +} + +function formatRelativeRu(iso: string): string { + const t = Date.parse(iso) + if (!Number.isFinite(t)) return iso + const sec = Math.max(0, Math.floor((Date.now() - t) / 1000)) + if (sec < 45) return "только что" + if (sec < 3600) return `${Math.max(1, Math.floor(sec / 60))} мин назад` + if (sec < 86400) return `${Math.max(1, Math.floor(sec / 3600))} ч назад` + const d = Math.floor(sec / 86400) + return `${d} д назад` +} + +function uniqStrings(xs: string[]): string[] { + const o: string[] = [] + const s = new Set() + for (const x of xs) { + const k = x.trim() + if (!k || s.has(k)) continue + s.add(k) + o.push(k) + } + return o +} + +/** Сводка условий для колонки `condition` в API / списке правил. */ +function summarizeConditionsUi(lines: string[]): string { + const u = uniqStrings(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}` +} + +function mapApiRules(rows: AlertsApiRule[]): AlertRule[] { + return rows.map((r) => { + const targets = r.targets?.length ? r.targets : r.target.trim() ? [r.target.trim()] : [] + const conditions = + r.conditions && r.conditions.length > 0 + ? r.conditions.map((c) => c.trim()).filter(Boolean) + : r.condition.trim() + ? [r.condition.trim()] + : [] + return { + id: r.id, + name: r.name, + type: r.type, + target: r.target, + targets, + groupId: r.groupId ?? null, + condition: r.condition, + conditions, + 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 ?? "", + lastFired: r.lastFiredAt ? formatRelativeRu(r.lastFiredAt) : null, + } + }) +} + +function mapApiGroups(rows: AlertsApiGroup[]): AlertGroup[] { + return rows.map((g) => ({ + id: g.id, + name: g.name, + combineMode: g.combineMode, + enabled: g.enabled, + cooldownOverride: g.cooldownOverride, + })) +} + +function mapApiHistory(rows: AlertsApiHistoryEntry[]): HistoryEntry[] { + return rows.map((e) => ({ + id: e.id, + ruleName: e.ruleName, + severity: e.severity, + message: e.message, + time: formatRelativeRu(e.firedAt), + sent: e.sent, + })) +} + +function toApiRuleBody(r: AlertRule) { + const targets = r.targets.length > 0 ? r.targets : r.target.trim() ? [r.target.trim()] : [] + const conditions = + r.conditions.length > 0 + ? r.conditions.map((c) => c.trim()).filter(Boolean) + : r.condition.trim() + ? [r.condition.trim()] + : [] + return { + id: r.id, + name: r.name, + type: r.type, + target: targets[0] ?? r.target, + targets, + groupId: r.groupId ?? null, + condition: conditions[0] ?? r.condition, + conditions, + severity: r.severity, + enabled: r.enabled, + cooldown: r.cooldown, + confirmStabilitySec: r.confirmStabilitySec ?? null, + recoveryMode: r.recoveryMode ?? "always", + recoveryStabilitySec: r.recoveryStabilitySec ?? null, + chatId: r.chatId ?? "", + } +} + +function toApiGroupBody(g: AlertGroup) { + return { + id: g.id, + name: g.name, + combineMode: g.combineMode, + enabled: g.enabled, + cooldownOverride: g.cooldownOverride, + } +} + +/** Снимок полей формы правила (без id / lastFired / enabled). */ +interface RuleFormSnapshot { + name: string + type: AlertType + targets: string[] + groupId: string | null + /** Шаблоны из TYPE_CONDITIONS (для типов с порогом — с «порога») */ + conditions: string[] + threshold: string + severity: AlertSeverity + cooldown: AlertCooldown + /** null — без задержки подтверждения */ + confirmStabilitySec: number | null + recoveryMode?: RecoveryMode + recoveryStabilitySec?: number | null + chatId: string +} + +function inferThresholdConditionTemplate(type: AlertType, line: string): string { + const t = line.trim().toLowerCase() + if (type === "rtt") return t.includes("<") ? "< порога" : "> порога" + if (type === "loss") return "> порога" + if (type === "traffic") return /tx\s* 0 + ? [...r.conditions] + : r.condition.trim() + ? [r.condition.trim()] + : [] + let threshold = "" + let conditions: string[] = [] + if (unit && (r.type === "rtt" || r.type === "loss" || r.type === "traffic")) { + for (const line of rawConds) { + const m = line.match(/(\d+(?:[.,]\d+)?)/) + if (m && !threshold) threshold = m[1]!.replace(",", ".") + conditions.push(inferThresholdConditionTemplate(r.type, line)) + } + conditions = uniqStrings(conditions) + } else { + conditions = uniqStrings(rawConds.map((x) => x.trim()).filter(Boolean)) + } + const targets = r.targets.length > 0 ? r.targets : r.target.trim() ? [r.target.trim()] : [] + return { + name: r.name, + type: r.type, + targets, + groupId: r.groupId ?? null, + conditions, + threshold, + severity: r.severity, + cooldown: r.cooldown, + confirmStabilitySec: r.confirmStabilitySec ?? null, + recoveryMode: r.recoveryMode ?? "always", + recoveryStabilitySec: r.recoveryStabilitySec ?? null, + chatId: r.chatId ?? "", + } +} + +/** Предлагаемое название по типу, объекту и условию. */ +function suggestAlertRuleName( + type: AlertType, + targets: string[], + conditions: string[], + threshold: string, +): string { + const typeLabel = TYPE_META[type].label + const tgt = + targets.length === 0 + ? "…" + : targets.length === 1 + ? targets[0]!.trim() || "…" + : `${targets.length} объектов` + const unit = THRESHOLD_UNIT[type] + let cond = + conditions.length === 0 + ? "…" + : conditions.length === 1 + ? conditions[0]!.trim() || "…" + : `${conditions.length} условий` + if (unit && threshold.trim() && cond.includes("порога")) { + cond = cond.replace("порога", `${threshold.trim()} ${unit}`) + } + const base = `${typeLabel}: ${tgt} — ${cond}` + return base.length > 110 ? `${base.slice(0, 107)}…` : base +} + +function emptyTypeTargets(): Record { + return { + "gre-tunnel": [], + "bgp-peer": [], + "bgp-prefix": [], + "gre-client": [], + server: [], + rtt: [], + loss: [], + traffic: [], + } +} + +/** Live: только данные из meta (и bgpPeers), без моковых подписей. */ +function buildLiveTypeTargets(meta: AlertsMeta): Record { + const srv = uniqStrings(meta.servers ?? []) + const bgp = uniqStrings(meta.bgpPeers ?? []) + const probes = uniqStrings(meta.probeTargets ?? []) + const rttLoss = meta.rttLossTargets.length ? uniqStrings(meta.rttLossTargets) : probes + const traffic = meta.trafficServers.length ? uniqStrings(meta.trafficServers) : srv + const greClients = uniqStrings([...(meta.greClients ?? []), ...srv]) + const greTunnels = uniqStrings(meta.greTunnelTargets ?? []) + return { + "gre-tunnel": greTunnels, + "bgp-peer": bgp, + "bgp-prefix": [], + "gre-client": greClients, + server: srv, + rtt: rttLoss, + loss: rttLoss, + traffic, + } +} + +const ALERT_TYPES_ORDER: AlertType[] = [ + "server", + "bgp-peer", + "bgp-prefix", + "gre-tunnel", + "gre-client", + "rtt", + "loss", + "traffic", +] + +const TYPE_EVENT_CARD_DESC: Record = { + "gre-tunnel": "Смена состояния GRE-туннеля между узлами", + "bgp-peer": "Разрыв или восстановление BGP-сессии", + "bgp-prefix": "Появление или отзыв префикса", + "gre-client": "Подключение или отключение GRE-клиента", + "server": "Доступность и деградация сервера в каталоге", + "rtt": "Порог задержки по ping-пробе", + "loss": "Порог потерь по ping-пробе", + "traffic": "Низкий RX/TX по данным сбора трафика", +} + // ─── static config ──────────────────────────────────────────────────────────── const TYPE_META: Record = { "traffic": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"], } +function splitGreTargetLabel(label: string): { tunnel: string; router: string } { + const i = label.indexOf(" / ") + if (i > 0) { + return { tunnel: label.slice(0, i).trim(), router: label.slice(i + 3).trim() } + } + return { tunnel: label.trim(), router: "" } +} + +function demoGreTunnelCatalog(): AlertsGreTunnelRow[] { + const sites = ["MSK", "SPB", "FRA", "AMS", "SGP"] as const + const countries = ["RU", "RU", "DE", "NL", "SG"] as const + return TYPE_TARGETS["gre-tunnel"].map((label, idx) => { + const { tunnel, router } = splitGreTargetLabel(label) + return { + targetLabel: label, + tunnelName: tunnel, + routerName: router || "—", + country: countries[idx % countries.length] ?? "RU", + site: sites[idx % sites.length] ?? "MSK", + remoteAddress: `10.${(idx % 220) + 1}.0.2`, + status: idx % 6 === 3 ? "degraded" : idx % 6 === 5 ? "down" : "up", + } + }) +} + +const GRE_CARD_STATUS: Record = { + up: { label: "Поднят", dot: "bg-emerald-500" }, + degraded: { label: "Деградация", dot: "bg-amber-500" }, + down: { label: "Offline", dot: "bg-red-500" }, +} + +/** ISO 3166-1 alpha-2 для `Flag`; иначе null. */ +function iso2CountryCode(country: string): string | null { + const t = country.replace(/\s/g, "").toUpperCase() + if (!t || t === "—" || t === "-") return null + if (t.length === 2 && /^[A-Z]{2}$/.test(t)) return t + return null +} + +/** Вторая строка в сводке / подсказка на карточке для типов без серверного/GRE-каталога. */ +function discreteTargetSubtitle(type: AlertType, target: string): string | null { + const t = target.trim() + if (!t) return null + if (type === "bgp-peer") { + const i = t.indexOf(":") + if (i > 0) { + const peer = t.slice(i + 1).trim() + if (peer) return `Сосед: ${peer}` + } + } + if (type === "rtt" || type === "loss") { + const i = t.indexOf("→") + if (i > 0) { + const right = t.slice(i + 1).trim() + if (right) return `Цель: ${right}` + } + } + return null +} + +/** Иконка строки условия (согласована с текстом варианта). */ +function conditionLeadingIcon(condition: string): { + Icon: React.FC<{ className?: string }> + className: string +} { + const c = condition.toLowerCase() + if (c.includes("разорвал")) return { Icon: UnplugIcon, className: "text-red-400/90" } + if (c.includes("восстановил сесс")) return { Icon: PlugIcon, className: "text-emerald-500/90" } + if (c.includes("offline") || c.includes("отключ")) return { Icon: WifiOffIcon, className: "text-red-400/90" } + if (c.includes("отозван")) return { Icon: RouteIcon, className: "text-orange-400/90" } + if (c.includes("получен")) return { Icon: RouteIcon, className: "text-sky-400/90" } + if (c.includes("восстанов") || c.includes("подключ")) return { Icon: CheckIcon, className: "text-emerald-500/90" } + if (c.includes("degraded")) return { Icon: AlertCircleIcon, className: "text-amber-500/90" } + if (c.includes(">")) return { Icon: ChevronUpIcon, className: "text-amber-500/90" } + if (c.includes("<")) return { Icon: ChevronDownIcon, className: "text-sky-400/90" } + return { Icon: BellIcon, className: "text-muted-foreground" } +} + const TYPE_CONDITIONS: Record = { "gre-tunnel": ["перешёл в offline", "восстановился"], "bgp-peer": ["разорвал сессию", "восстановил сессию"], @@ -120,19 +596,38 @@ const THRESHOLD_UNIT: Partial> = { const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"] +/** Задержка подтверждения: уведомление только если условие держится без «отмены» (пропало срабатывание). */ +const CONFIRM_STABILITY_OPTIONS: { value: number | null; label: string }[] = [ + { value: null, label: "Нет" }, + { value: 30, label: "30 с" }, + { value: 60, label: "1 мин" }, + { value: 120, label: "2 мин" }, + { value: 300, label: "5 мин" }, + { value: 900, label: "15 мин" }, + { value: 3600, label: "1 ч" }, +] + +function formatConfirmStabilityShort(sec: number | null | undefined): string | null { + if (sec == null || sec <= 0) return null + const o = CONFIRM_STABILITY_OPTIONS.find((x) => x.value === sec) + return o?.label ?? `${sec} с` +} + // ─── mock data ──────────────────────────────────────────────────────────────── const INIT_RULES: AlertRule[] = [ - { id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "5м", lastFired: "3ч назад", chatId: "" }, - { id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01", condition: "разорвал сессию", severity: "critical", enabled: true, cooldown: "15м", lastFired: "1д назад", chatId: "" }, - { id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", condition: "> 120 мс", severity: "warning", enabled: true, cooldown: "15м", lastFired: "45м назад", chatId: "" }, - { id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", condition: "> 5%", severity: "warning", enabled: true, cooldown: "5м", lastFired: null, chatId: "" }, - { id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "1ч", lastFired: "2д назад", chatId: "" }, - { id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", condition: "был отозван", severity: "info", enabled: true, cooldown: "5м", lastFired: "6ч назад", chatId: "-1009876543210" }, - { id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", condition: "отключился", severity: "warning", enabled: false, cooldown: "1ч", lastFired: null, chatId: "" }, - { id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", condition: "RX < 10 Мбит/с", severity: "info", enabled: false, cooldown: "4ч", lastFired: null, chatId: "" }, + { id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", targets: ["gre-ivanov-01 / core-01"], groupId: null, condition: "перешёл в offline", conditions: ["перешёл в offline"], severity: "critical", enabled: true, cooldown: "5м", confirmStabilitySec: null, lastFired: "3ч назад", chatId: "" }, + { id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01: uplink-1", targets: ["mt-msk-core-01: uplink-1"], groupId: null, condition: "разорвал сессию", conditions: ["разорвал сессию"], severity: "critical", enabled: true, cooldown: "15м", confirmStabilitySec: null, lastFired: "1д назад", chatId: "" }, + { id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", targets: ["mt-fra-edge-01 → 8.8.8.8"], groupId: null, condition: "> 120 мс", conditions: ["> 120 мс"], severity: "warning", enabled: true, cooldown: "15м", confirmStabilitySec: null, lastFired: "45м назад", chatId: "" }, + { id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", targets: ["mt-ams-edge-01 → 1.1.1.1"], groupId: null, condition: "> 5%", conditions: ["> 5%"], severity: "warning", enabled: true, cooldown: "5м", confirmStabilitySec: null, lastFired: null, chatId: "" }, + { id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", targets: ["mt-sgp-edge-01"], groupId: null, condition: "перешёл в offline", conditions: ["перешёл в offline"], severity: "critical", enabled: true, cooldown: "1ч", confirmStabilitySec: null, lastFired: "2д назад", chatId: "" }, + { id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", targets: ["любой префикс"], groupId: null, condition: "был отозван", conditions: ["был отозван"], severity: "info", enabled: true, cooldown: "5м", confirmStabilitySec: null, lastFired: "6ч назад", chatId: "-1009876543210" }, + { id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", targets: ["gre-kozlov-01"], groupId: null, condition: "отключился", conditions: ["отключился"], severity: "warning", enabled: false, cooldown: "1ч", confirmStabilitySec: null, lastFired: null, chatId: "" }, + { id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", targets: ["mt-ams-edge-01"], groupId: null, condition: "RX < 10 Мбит/с", conditions: ["RX < 10 Мбит/с"], severity: "info", enabled: false, cooldown: "4ч", confirmStabilitySec: null, lastFired: null, chatId: "" }, ] +const INIT_GROUPS: AlertGroup[] = [] + const INIT_HISTORY: HistoryEntry[] = [ { id: "h1", ruleName: "Высокая задержка FRA", severity: "warning", message: "RTT mt-fra-edge-01 → 8.8.8.8: 137 мс (порог 120 мс)", time: "45м назад", sent: true }, { id: "h2", ruleName: "GRE-туннель Иванов offline", severity: "critical", message: "gre-ivanov-01 / core-01 перешёл в offline", time: "3ч назад", sent: true }, @@ -141,19 +636,70 @@ const INIT_HISTORY: HistoryEntry[] = [ { id: "h5", ruleName: "Сервер SGP offline", severity: "critical", message: "mt-sgp-edge-01 недоступен, ping timeout", time: "2д назад", sent: false }, ] +type AlertPresetDef = { + id: string + title: string + description: string + build: (serverNames: string[]) => Omit[] +} + +const ALERT_PRESETS: AlertPresetDef[] = [ + { + id: "srv-offline-pack", + title: "Offline по серверам", + description: "По одному правилу на каждый сервер из каталога (до 24 шт.).", + build: (serverNames) => + serverNames.slice(0, 24).map((name) => ({ + name: `Сервер ${name}: offline`, + type: "server" as const, + target: name, + targets: [name], + groupId: null, + condition: "перешёл в offline", + conditions: ["перешёл в offline"], + severity: "critical" as const, + cooldown: "5м" as const, + confirmStabilitySec: null, + chatId: "", + })), + }, + { + id: "rtt-8888-pack", + title: "RTT → 8.8.8.8", + description: "До 6 правил: задержка выше 100 мс для первых серверов.", + build: (serverNames) => + serverNames.slice(0, 6).map((name) => ({ + name: `RTT ${name} → 8.8.8.8`, + type: "rtt" as const, + target: `${name} → 8.8.8.8`, + targets: [`${name} → 8.8.8.8`], + groupId: null, + condition: "> 100 мс", + conditions: ["> 100 мс"], + severity: "warning" as const, + cooldown: "15м" as const, + confirmStabilitySec: null, + chatId: "", + })), + }, +] + const INIT_TG: TelegramConfig = { - token: "7412358964:AAFkL9xZqBb2pC8nYrVtHmwKjXeOdSuN1A", - chatId: "-1001234567890", - connected: true, + token: "", + chatId: "", + messageThreadId: "", + connected: false, } // ─── small helpers ──────────────────────────────────────────────────────────── -function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { +function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { return ( - + )} @@ -276,26 +832,38 @@ function AlertRuleRow({ rule, onToggle, onDelete }: { // ─── telegram config card ───────────────────────────────────────────────────── -function TelegramCard({ cfg, onChange }: { +function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTelegram, onTestTelegram }: { cfg: TelegramConfig onChange: (c: TelegramConfig) => void + /** В режиме live: токен в БД есть, в поле не подставляем */ + tokenConfigured?: boolean + liveSaveBusy?: boolean + onSaveTelegram?: () => void | Promise + onTestTelegram?: () => void | Promise }) { const [showToken, setShowToken] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null) - const handleTest = () => { + const handleTest = async () => { setTesting(true) setTestResult(null) - setTimeout(() => { + try { + if (onTestTelegram) { + await onTestTelegram() + setTestResult("ok") + } else { + await new Promise((r) => setTimeout(r, 450)) + setTestResult(cfg.connected ? "ok" : "fail") + } + } catch { + setTestResult("fail") + } finally { setTesting(false) - setTestResult(cfg.connected ? "ok" : "fail") - setTimeout(() => setTestResult(null), 3000) - }, 1400) + setTimeout(() => setTestResult(null), 4000) + } } - const _maskedToken = cfg.token.replace(/:.+/, ":••••••••••••••••••••••••") - return ( @@ -324,8 +892,10 @@ function TelegramCard({ cfg, onChange }: { type={showToken ? "text" : "password"} value={cfg.token} onChange={e => onChange({ ...cfg, token: e.target.value })} + autoComplete="new-password" + name="telegram-bot-token" className="text-xs font-mono h-8" - placeholder="1234567890:AAF..." + placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."} /> + )} + {/* test button */} + ) + })} + + + )} + + ) +} + +/** Карточки GRE: страна/площадка, роутер, имя интерфейса, remote, статус. */ +function GreTunnelObjectCardPicker({ + label, + rows, + selected, + onToggle, + hideLabel, + listUnconstrained, +}: { + label: string + rows: AlertsGreTunnelRow[] + selected: string[] + onToggle: (targetLabel: string) => void + hideLabel?: boolean + listUnconstrained?: boolean +}) { + const [query, setQuery] = useState("") + + useEffect(() => { + queueMicrotask(() => { + setQuery("") + }) + }, [rows]) + + const filteredRows = useMemo(() => { + const s = query.trim().toLowerCase() + if (!s) return rows + return rows.filter((r) => { + const cc = iso2CountryCode(r.country) + const hay = [ + r.tunnelName, + r.routerName, + r.site, + r.country, + r.remoteAddress, + r.targetLabel, + r.comment ?? "", + cc ? countryName(cc) : "", + ] + .join(" ") + .toLowerCase() + return hay.includes(s) + }) + }, [rows, query]) + + return ( +
+ {!hideLabel ? {label} : null} + {rows.length === 0 ? ( +

Нет туннелей для выбора

+ ) : ( + <> +
+ + setQuery(e.target.value)} + autoComplete="off" + name="alert-gre-search" + placeholder="Поиск: имя, роутер, IP, площадка…" + className="h-8 pl-8 pr-2 text-xs" + aria-label="Поиск по списку GRE-туннелей" + /> +
+
+ {filteredRows.length === 0 ? ( +

Ничего не найдено

+ ) : null} + {filteredRows.map((r) => { + const active = selected.includes(r.targetLabel) + const st = GRE_CARD_STATUS[r.status] ?? { label: String(r.status), dot: "bg-muted-foreground" } + const cc = iso2CountryCode(r.country) + const siteLine = + r.site && r.site !== "—" + ? r.site + : cc + ? countryName(cc) + : "—" + return ( + + ) + })} +
+ + )} +
+ ) +} + +/** Карточки объекта для типов со строковым списком (BGP, префиксы, RTT/loss и т.д.): поиск + иконка типа. */ +function StringOptionCardPicker({ + label, + options, + selected, + onToggle, + hideLabel, + listUnconstrained, + ruleType, +}: { + label: string + options: string[] + selected: string[] + onToggle: (opt: string) => void + hideLabel?: boolean + listUnconstrained?: boolean + ruleType: AlertType +}) { + const [query, setQuery] = useState("") + const m = TYPE_META[ruleType] + const Icon = m.Icon + + useEffect(() => { + queueMicrotask(() => { + setQuery("") + }) + }, [options]) + + const filtered = useMemo(() => { + const s = query.trim().toLowerCase() + if (!s) return options + return options.filter((o) => o.toLowerCase().includes(s)) + }, [options, query]) + + return ( +
+ {!hideLabel ? {label} : null} + {options.length === 0 ? ( +

Нет объектов для выбора

+ ) : ( + <> +
+ + setQuery(e.target.value)} + autoComplete="off" + name="alert-target-search" + placeholder="Поиск по списку объектов…" + className="h-8 pl-8 pr-2 text-xs" + aria-label="Поиск по списку объектов" + /> +
+
+ {filtered.length === 0 ? ( +

Ничего не найдено

+ ) : null} + {filtered.map((opt) => { + const active = selected.includes(opt) + const hint = discreteTargetSubtitle(ruleType, opt) + return ( + + ) + })} +
+ + )} +
+ ) +} + +/** Выбор условия: как объекты — несколько карточек, OR по условиям. */ +function ConditionOptionCardPicker({ + label, + conditions, + selected, + onToggle, + hideLabel, + listUnconstrained, + ruleType, +}: { + label: string + conditions: string[] + selected: string[] + onToggle: (v: string) => void + hideLabel?: boolean + listUnconstrained?: boolean + ruleType: AlertType +}) { + const [query, setQuery] = useState("") + const m = TYPE_META[ruleType] + + useEffect(() => { + queueMicrotask(() => { + setQuery("") + }) + }, [conditions]) + + const filtered = useMemo(() => { + const s = query.trim().toLowerCase() + if (!s) return conditions + return conditions.filter((o) => o.toLowerCase().includes(s)) + }, [conditions, query]) + + return ( +
+ {!hideLabel ? {label} : null} + {conditions.length === 0 ? ( +

Нет условий для этого типа

+ ) : ( + <> +
+ + setQuery(e.target.value)} + autoComplete="off" + name="alert-condition-search" + placeholder="Поиск по вариантам условия…" + className="h-8 pl-8 pr-2 text-xs" + aria-label="Поиск по условиям" + /> +
+
+ {filtered.length === 0 ? ( +

Ничего не найдено

+ ) : null} + {filtered.map((opt) => { + const active = selected.includes(opt) + const { Icon: CondIcon, className: condIconCls } = conditionLeadingIcon(opt) + return ( + + ) + })} +
+ + )} +
+ ) +} + +/** Свернутый выбранный объект — развернуть список снова (без второй рамки: внешний контейнер уже обводит блок). */ +function ObjectPickSummaryRow({ + ruleType, + targets, + server, + greTunnel, + onExpand, +}: { + ruleType: AlertType + targets: string[] + server?: AlertsServerRow + greTunnel?: AlertsGreTunnelRow + onExpand: () => void +}) { + const target = targets[0] ?? "" + const multiHint = targets.length > 1 ? ` (+${targets.length - 1})` : "" + + if (greTunnel) { + const cc = iso2CountryCode(greTunnel.country) + const siteLine = + greTunnel.site && greTunnel.site !== "—" + ? greTunnel.site + : cc + ? countryName(cc) + : "—" + return ( + + ) + } + + if (server) { + const cc = iso2CountryCode(server.country) + const line2 = (() => { + const h = (server.host ?? "").trim() + if (h && h !== target.trim()) return { text: h, mono: true as const } + const loc = [server.site, server.country].filter(Boolean).join(" · ") + if (loc) return { text: loc, mono: false as const } + const role = SERVER_ROLES_RU[server.type] ?? server.type + if (role) return { text: role, mono: false as const } + return null + })() + return ( + + ) + } + + const m = TYPE_META[ruleType] + const Icon = m.Icon + const sub = discreteTargetSubtitle(ruleType, target) + const joined = targets.length > 1 ? targets.join(", ") : target + return ( + + ) +} + // ─── add rule sheet ─────────────────────────────────────────────────────────── -const BLANK_FORM = { +const BLANK_FORM: RuleFormSnapshot = { name: "", - type: "gre-tunnel" as AlertType, - target: "", - condition: "", + type: "server", + targets: [], + groupId: null, + conditions: [], threshold: "", - severity: "warning" as AlertSeverity, - cooldown: "5м" as AlertCooldown, + severity: "warning", + cooldown: "5м", + confirmStabilitySec: null, chatId: "", } -function AddRuleSheet({ open, onClose, onSave }: { +type RuleTelegramTestPayload = { + name: string + targets: string[] + conditionLine: string + severity: AlertSeverity + cooldown: AlertCooldown + ruleChatId: string +} + +function AddRuleSheet({ + open, + onClose, + onSave, + typeTargets, + isLive, + serversCatalog, + greTunnelCatalog, + groups, + initialForm, + editRuleId, + onTestRuleTelegram, + testTelegramDisabled, +}: { open: boolean onClose: () => void - onSave: (rule: Omit) => void + /** editRuleId задан — обновление существующего правила */ + onSave: (rule: Omit, editRuleId: string | null) => void + typeTargets: Record + isLive: boolean + serversCatalog: AlertsServerRow[] + greTunnelCatalog: AlertsGreTunnelRow[] + groups: AlertGroup[] + initialForm: RuleFormSnapshot | null + editRuleId: string | null + /** Live: отправка тестового сообщения с текстом как у сохранённого правила (без записи в БД). */ + onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise + /** true — кнопка теста неактивна (например, бэкенд недоступен). */ + testTelegramDisabled?: boolean }) { - const [form, setForm] = useState({ ...BLANK_FORM }) + const [form, setForm] = useState(() => ({ + ...BLANK_FORM, + ...(initialForm ?? {}), + })) + const nameTouchedRef = useRef(Boolean(editRuleId)) + /** Список объектов не показываем сразу — после фокуса/клика по другому полю шита. Редактирование с выбранными объектами — сразу. */ + const [objectPickerRevealed, setObjectPickerRevealed] = useState( + () => Boolean(editRuleId && (initialForm?.targets?.length ?? 0) > 0), + ) + const revealObjectPicker = useCallback(() => { + setObjectPickerRevealed(true) + }, []) + /** Для списка объектов: после выбора сворачиваем список, ниже показываем условия. */ + const [objectExpanded, setObjectExpanded] = useState( + () => !(initialForm?.targets?.length), + ) + const [testTelegramBusy, setTestTelegramBusy] = useState(false) + const [testTelegramHint, setTestTelegramHint] = useState(null) - // reset when open - const handleOpen = (o: boolean) => { if (o) setForm({ ...BLANK_FORM }) } - - const targets = TYPE_TARGETS[form.type] + const targets = typeTargets[form.type] const conditions = TYPE_CONDITIONS[form.type] + const targetsEmpty = targets.length === 0 + + const objectServerCardRows = useMemo(() => { + if (!OBJECT_TYPES_WITH_SERVER_CARDS.includes(form.type)) return [] + if (!serversCatalog.length) return [] + const pick = new Set(targets) + return serversCatalog.filter((s) => pick.has(serverRowLabel(s))) + }, [form.type, serversCatalog, targets]) + + const objectGreCardRows = useMemo(() => { + if (form.type !== "gre-tunnel" || !greTunnelCatalog.length) return [] + const pick = new Set(targets) + return greTunnelCatalog.filter((r) => pick.has(r.targetLabel)) + }, [form.type, greTunnelCatalog, targets]) + + const useGreTunnelObjectCards = !targetsEmpty && objectGreCardRows.length > 0 + const useServerObjectCards = !targetsEmpty && objectServerCardRows.length > 0 const unit = THRESHOLD_UNIT[form.type] + const allowManualTarget = targetsEmpty + const targetOk = allowManualTarget ? Boolean(form.targets[0]?.trim()) : form.targets.length > 0 + const hasObject = form.targets.length > 0 - const conditionDisplay = unit && form.threshold - ? `${form.condition.replace("порога", form.threshold + " " + unit)}` - : form.condition + const objectSummaryServer = useMemo((): AlertsServerRow | undefined => { + if (!useServerObjectCards) return undefined + for (const t of form.targets) { + const s = objectServerCardRows.find((x) => serverRowLabel(x) === t.trim()) + if (s) return s + } + return undefined + }, [useServerObjectCards, objectServerCardRows, form.targets]) - const canSave = form.name.trim() && form.target && form.condition && (!unit || form.threshold) + const objectSummaryGre = useMemo((): AlertsGreTunnelRow | undefined => { + if (!useGreTunnelObjectCards) return undefined + for (const t of form.targets) { + const r = objectGreCardRows.find((x) => x.targetLabel === t.trim()) + if (r) return r + } + return undefined + }, [useGreTunnelObjectCards, objectGreCardRows, form.targets]) + + const toggleTarget = useCallback((v: string) => { + const t = v.trim() + if (!t) return + nameTouchedRef.current = false + setForm((f) => { + const set = new Set(f.targets) + if (set.has(t)) set.delete(t) + else set.add(t) + const next = [...set] + // Не сворачиваем после каждого клика по карточке — иначе мульти-выбор серверов невозможен без повторного «Изменить». + queueMicrotask(() => { + if (next.length === 0) setObjectExpanded(true) + }) + return { ...f, targets: next } + }) + }, []) + + useEffect(() => { + if (!open) return + if (nameTouchedRef.current) return + const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0 + queueMicrotask(() => { + if (trivialEmpty) { + if (!editRuleId) { + setForm((f) => (f.name === "" ? f : { ...f, name: "" })) + } + return + } + const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold) + setForm((f) => (f.name === nextName ? f : { ...f, name: nextName })) + }) + }, [open, editRuleId, form.type, form.targets, form.conditions, form.threshold]) + + const expandedConditionLines = useMemo( + () => + form.conditions.map((c) => + unit && form.threshold.trim() && c.includes("порога") + ? c.replace("порога", `${form.threshold.trim()} ${unit}`) + : c, + ), + [form.conditions, form.threshold, unit], + ) + + const conditionDisplay = expandedConditionLines.join("\n") + + const canSave = + form.name.trim() && targetOk && form.conditions.length > 0 && (!unit || form.threshold) + + const handleTestRuleTelegram = async () => { + if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return + setTestTelegramBusy(true) + setTestTelegramHint(null) + try { + const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions) + await onTestRuleTelegram({ + name: form.name.trim(), + targets: uniqStrings(form.targets), + conditionLine, + severity: form.severity, + cooldown: form.cooldown, + ruleChatId: form.chatId.trim(), + }) + setTestTelegramHint("__ok__") + window.setTimeout(() => setTestTelegramHint(null), 5000) + } catch (e) { + setTestTelegramHint(e instanceof Error ? e.message : "Не удалось отправить тест") + } finally { + setTestTelegramBusy(false) + } + } const handleSave = () => { if (!canSave) return + const uq = uniqStrings(form.targets) + const targetSummary = uq.join(", ").slice(0, 240) || "—" + const condResolved = uniqStrings(expandedConditionLines.map((c) => c.trim()).filter(Boolean)) onSave({ name: form.name.trim(), type: form.type, - target: form.target, - condition: conditionDisplay || form.condition, + target: targetSummary, + targets: uq, + groupId: form.groupId && groups.some((g) => g.id === form.groupId) ? form.groupId : null, + condition: summarizeConditionsUi(condResolved), + conditions: condResolved, severity: form.severity, cooldown: form.cooldown, + confirmStabilitySec: form.confirmStabilitySec, chatId: form.chatId.trim(), - }) + }, editRuleId) onClose() } + const applySuggestedName = useCallback(() => { + nameTouchedRef.current = false + const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0 + if (!editRuleId && trivialEmpty) { + setForm((f) => ({ ...f, name: "" })) + return + } + const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold) + setForm((f) => ({ ...f, name: nextName })) + }, [editRuleId, form.type, form.targets, form.conditions, form.threshold]) + + const toggleCondition = useCallback((v: string) => { + const t = v.trim() + if (!t) return + nameTouchedRef.current = false + setForm((f) => { + const set = new Set(f.conditions) + if (set.has(t)) set.delete(t) + else set.add(t) + const next = [...set] + queueMicrotask(() => setObjectExpanded(false)) + return { ...f, conditions: next } + }) + }, []) + return ( - { if (!o) onClose(); handleOpen(o) }}> + { if (!o) onClose() }}> {/* p-0 + gap-0: we control all spacing internally */} - + {/* ── fixed header ── */} - Новое правило оповещения + {editRuleId ? "Изменение правила" : "Новое правило оповещения"} @@ -482,42 +1910,245 @@ function AddRuleSheet({ open, onClose, onSave }: { {/* name */}
- Название правила - setForm(f => ({ ...f, name: e.target.value }))} - /> -
- - {/* type */} -
- Тип события - setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))} - > - {(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => ( - - ))} - -
- - {/* target + condition on same row when both are simple selects */} -
-
- Объект - setForm(f => ({ ...f, target: v }))}> - - {targets.map(t => )} - +
+ Название правила +
-
- Условие - setForm(f => ({ ...f, condition: v }))}> - - {conditions.map(c => )} - + { + nameTouchedRef.current = true + setForm((f) => ({ ...f, name: e.target.value })) + }} + /> + {!editRuleId && ( +

+ Название пересобирается при смене типа, объекта, условия или порога. После ручного ввода в поле автоподстановка отключается. +

+ )} +
+ + {/* type — карточки как «Действие» на /filters */} +
+ Тип события +
+ {ALERT_TYPES_ORDER.map((t) => { + const m = TYPE_META[t] + const { Icon, iconClass, bg } = m + const active = form.type === t + return ( + + ) + })} +
+
+ + + + {/* Объект и условие — вертикально; после выбора объекта список сворачивается, условия появляются */} +
+ {allowManualTarget ? ( +
+ Объект + {!objectPickerRevealed ? ( +

+ Поле ввода появится после клика или фокуса на названии правила, типе события, серьёзности, + пороге, группе или Chat ID. +

+ ) : ( + <> + { + nameTouchedRef.current = false + const v = e.target.value.trim() + setForm(f => ({ ...f, targets: v ? [v] : [] })) + queueMicrotask(() => setObjectExpanded(!v)) + }} + className="text-xs font-mono h-8" + placeholder={ + isLive + ? "Точное имя объекта (сервер, сосед, префикс…)" + : "Или выберите из списка в демо-режиме" + } + /> + {isLive && ( +

+ Нет готового списка для этого типа — укажите объект вручную (как в данных мониторинга). +

+ )} + + )} +
+ ) : ( +
+ Объект +

+ Можно выбрать несколько карточек — сработает при событии на любом из них (OR). +

+ {!objectPickerRevealed ? ( +

+ Список объектов появится после клика или фокуса на названии правила, типе события, серьёзности, + пороге, группе или Chat ID. +

+ ) : ( +
+
+
+
+ {useGreTunnelObjectCards ? ( + + ) : useServerObjectCards ? ( + + ) : ( + + )} +
+
+
+
+
+
+ { + revealObjectPicker() + setObjectExpanded(true) + }} + /> +
+
+
+
+ )} +
+ )} + +
+ Условие + {!hasObject ? ( +

+ {allowManualTarget + ? "Укажите объект выше — затем появится выбор условия." + : "Сначала выберите объект (можно несколько). Варианты условия появятся ниже; после выбора условия список объектов свернётся — снова развернуть кнопкой «Изменить»."} +

+ ) : ( + <> +

+ Можно выбрать несколько карточек — уведомление при срабатывании любого из условий (OR), например offline и восстановление. +

+
+
+ +
+
+ + )}
@@ -529,7 +2160,11 @@ function AddRuleSheet({ open, onClose, onSave }: { setForm(f => ({ ...f, threshold: e.target.value }))} + onFocus={revealObjectPicker} + onChange={e => { + nameTouchedRef.current = false + setForm((f) => ({ ...f, threshold: e.target.value })) + }} className="rounded-r-none" placeholder="0" /> @@ -548,7 +2183,14 @@ function AddRuleSheet({ open, onClose, onSave }: { {(["critical", "warning", "info"] as AlertSeverity[]).map(s => { const active = form.severity === s return ( -
- {/* chat id override */} + {/* подтверждение стабильности (антидребезг) */} +
+ Задержка перед отправкой +
+ {CONFIRM_STABILITY_OPTIONS.map((o) => { + const active = + (o.value == null && (form.confirmStabilitySec == null || form.confirmStabilitySec <= 0)) || + (o.value != null && form.confirmStabilitySec === o.value) + return ( + + ) + })} +
+ + Если за выбранное время условие перестанет выполняться (сигнал «отменится»), уведомление не отправится. + Не зависит от cooldown: сначала ждём стабильность, затем проверяем cooldown после фактической отправки. + +
+ +
+ Группа правил (ANY / ALL) + + + ANY — уведомление при срабатывании любого правила в группе. ALL — когда сработали все правила группы одновременно. + +
+
Chat ID — переопределить setForm(f => ({ ...f, chatId: e.target.value }))} className="font-mono text-sm" placeholder="-100xxxxxxxxxx" @@ -600,7 +2311,7 @@ function AddRuleSheet({ open, onClose, onSave }: {
{/* Telegram message preview */} - {form.name && form.target && form.condition && ( + {form.name && form.targets.length > 0 && form.conditions.length > 0 && (
@@ -612,9 +2323,9 @@ function AddRuleSheet({ open, onClose, onSave }: { {form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"} {" "}{form.name}

-

Объект: {form.target}

-

- Событие: {conditionDisplay || form.condition} +

Объект: {form.targets.join(", ")}

+

+ Событие: {conditionDisplay || summarizeConditionsUi(form.conditions)}

Cooldown: {form.cooldown}

@@ -625,11 +2336,51 @@ function AddRuleSheet({ open, onClose, onSave }: { )}

+ {isLive && onTestRuleTelegram && testTelegramHint ? ( +
+ {testTelegramHint === "__ok__" + ? "Тестовое сообщение отправлено в Telegram." + : testTelegramHint} +
+ ) : null} + {/* ── fixed footer ── */} - - - + {isLive && onTestRuleTelegram ? ( + + ) : null} + @@ -642,13 +2393,367 @@ function AddRuleSheet({ open, onClose, onSave }: { type SeverityFilter = AlertSeverity | "all" export default function AlertsPage() { - const [rules, setRules] = useState(INIT_RULES) - const [history] = useState(INIT_HISTORY) - const [tg, setTg] = useState(INIT_TG) + const { mode, backendUrl, backendStatus } = useDataSource() + const isLive = mode === "live" + const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + + const [rules, setRules] = useState(INIT_RULES) + const [groups, setGroups] = useState(INIT_GROUPS) + const [history, setHistory] = useState(INIT_HISTORY) + const [tg, setTg] = useState(INIT_TG) + const [tokenConfigured, setTokenConfigured] = useState(false) + const [meta, setMeta] = useState(null) + const [sheetOpen, setSheetOpen] = useState(false) + const [sheetSnap, setSheetSnap] = useState(null) + const [sheetEditId, setSheetEditId] = useState(null) + const [sheetKey, setSheetKey] = useState(0) const [sevFilter, setSevFilter] = useState("all") const [onlyActive, setOnlyActive] = useState(false) - const [search, setSearch] = useState("") + const [search, setSearch] = useState("") + + const [loadBusy, setLoadBusy] = useState(false) + const [loadError, setLoadError] = useState(null) + const [rulesSaveBusy, setRulesSaveBusy] = useState(false) + const [telegramSaveBusy, setTelegramSaveBusy] = useState(false) + /** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */ + const [extrasExpanded, setExtrasExpanded] = useState(false) + const [hintsExpanded, setHintsExpanded] = useState(false) + + const typeTargets = useMemo(() => { + if (!isLive) return TYPE_TARGETS + if (!meta) return emptyTypeTargets() + return buildLiveTypeTargets(meta) + }, [isLive, meta]) + + const serversCatalog = useMemo((): AlertsServerRow[] => { + if (isLive && meta?.serversDetail?.length) return meta.serversDetail + if (!isLive) { + return TYPE_TARGETS.server.map((name, i) => ({ + name, + host: `192.0.2.${(i % 220) + 10}`, + site: ["MSK", "SPB", "FRA", "AMS", "SGP"][i % 5] ?? "", + country: ["RU", "RU", "DE", "NL", "SG"][i % 5] ?? "", + type: (["jump-host", "exit-node", "home-router"] as const)[i % 3] as string, + })) + } + return [] + }, [isLive, meta]) + + const greTunnelCatalog = useMemo((): AlertsGreTunnelRow[] => { + if (isLive && meta?.greTunnelDetail?.length) return meta.greTunnelDetail + if (!isLive) return demoGreTunnelCatalog() + return meta?.greTunnelDetail ?? [] + }, [isLive, meta]) + + const load = useCallback(async () => { + if (!isLive) { + setRules(INIT_RULES) + setGroups(INIT_GROUPS) + setHistory(INIT_HISTORY) + setTg(INIT_TG) + setTokenConfigured(false) + setMeta(null) + setLoadError(null) + return + } + setLoadBusy(true) + setLoadError(null) + try { + const data = await apiFetch<{ + telegram: { + chatId: string + tokenConfigured: boolean + connected: boolean + messageThreadId: number | null + } + groups?: AlertsApiGroup[] + rules: AlertsApiRule[] + history: AlertsApiHistoryEntry[] + meta: AlertsMeta + }>("/api/alerts") + setTokenConfigured(data.telegram.tokenConfigured) + setTg({ + token: "", + chatId: data.telegram.chatId ?? "", + messageThreadId: + data.telegram.messageThreadId != null ? String(data.telegram.messageThreadId) : "", + connected: data.telegram.connected, + }) + setRules(mapApiRules(data.rules)) + setGroups(mapApiGroups(data.groups ?? [])) + setHistory(mapApiHistory(data.history)) + let mergedMeta: AlertsMeta = data.meta + try { + const bgpRows = await apiFetch("/api/bgp/sessions") + const bgpPeers = uniqStrings( + bgpRows.map((s) => `${String(s.serverName ?? "").trim()}: ${String(s.name ?? "").trim()}`.trim()), + ) + mergedMeta = { ...data.meta, bgpPeers } + } catch { + mergedMeta = { ...data.meta, bgpPeers: [] } + } + try { + const [srvList, greRes] = await Promise.all([ + apiFetch< + Array<{ id: number; name: string; site?: string; country?: string; host?: string }> + >("/api/servers"), + apiFetch<{ + tunnels: Array<{ + id?: string + name: string + serverId: string + remoteAddress?: string + status?: string + comment?: string + }> + }>("/api/filters/gre-tunnels"), + ]) + const sidToSrv = new Map( + srvList.map((s) => [ + String(s.id), + { + name: String(s.name ?? "").trim() || String(s.id), + site: String(s.site ?? "").trim(), + country: String(s.country ?? "").trim(), + host: String(s.host ?? "").trim(), + }, + ]), + ) + const greTunnelDetail: AlertsGreTunnelRow[] = greRes.tunnels.map((t) => { + const srv = sidToSrv.get(String(t.serverId)) + const sn = srv?.name ?? String(t.serverId) + const tn = (String(t.name ?? "").trim() || String(t.id ?? "").trim() || "gre").trim() + const label = `${tn} / ${sn}`.trim() + const rawSt = (t.status ?? "down").toLowerCase() + const status: AlertsGreTunnelRow["status"] = + rawSt === "up" || rawSt === "down" || rawSt === "degraded" ? rawSt : "degraded" + return { + targetLabel: label, + tunnelName: tn, + routerName: sn, + country: srv?.country ? srv.country : "—", + site: srv?.site ? srv.site : "—", + remoteAddress: (String(t.remoteAddress ?? "").trim() || "—"), + status, + comment: (t.comment ?? "").trim() || undefined, + } + }) + const greTunnelTargets = uniqStrings(greTunnelDetail.map((r) => r.targetLabel)) + mergedMeta = { ...mergedMeta, greTunnelTargets, greTunnelDetail } + } catch { + mergedMeta = { ...mergedMeta, greTunnelTargets: [], greTunnelDetail: [] } + } + setMeta(mergedMeta) + } catch (e) { + setLoadError(e instanceof Error ? e.message : "Ошибка загрузки") + } finally { + setLoadBusy(false) + } + }, [apiFetch, isLive]) + + useEffect(() => { + queueMicrotask(() => { + void load() + }) + }, [load]) + + const flushAlertsConfig = useCallback(async (nextRules: AlertRule[], nextGroups: AlertGroup[]) => { + setRules(nextRules) + setGroups(nextGroups) + if (!isLive) return + setRulesSaveBusy(true) + setLoadError(null) + try { + const res = await apiFetch<{ rules: AlertsApiRule[]; groups: AlertsApiGroup[] }>("/api/alerts/rules", { + method: "PUT", + body: JSON.stringify({ + rules: nextRules.map(toApiRuleBody), + groups: nextGroups.map(toApiGroupBody), + }), + }) + setRules(mapApiRules(res.rules)) + setGroups(mapApiGroups(res.groups ?? [])) + } catch (e) { + setLoadError(e instanceof Error ? e.message : "Не удалось сохранить правила и группы") + await load() + } finally { + setRulesSaveBusy(false) + } + }, [apiFetch, isLive, load]) + + const saveTelegram = useCallback(async () => { + if (!isLive) return + const threadRaw = tg.messageThreadId.trim() + let messageThreadId: number | null = null + if (threadRaw) { + const n = Number.parseInt(threadRaw, 10) + if (!Number.isFinite(n) || n < 1) { + setLoadError("ID темы: укажите положительное целое число или оставьте поле пустым") + return + } + messageThreadId = n + } + setTelegramSaveBusy(true) + setLoadError(null) + try { + const res = await apiFetch<{ + telegram: { + chatId: string + tokenConfigured: boolean + connected: boolean + messageThreadId: number | null + } + }>( + "/api/alerts/telegram", + { + method: "PUT", + body: JSON.stringify({ + chatId: tg.chatId, + messageThreadId, + ...(tg.token.trim() ? { token: tg.token.trim() } : {}), + }), + }, + ) + setTokenConfigured(res.telegram.tokenConfigured) + setTg({ + token: "", + chatId: res.telegram.chatId ?? "", + messageThreadId: + res.telegram.messageThreadId != null ? String(res.telegram.messageThreadId) : "", + connected: res.telegram.connected, + }) + } catch (e) { + setLoadError(e instanceof Error ? e.message : "Не удалось сохранить Telegram") + } finally { + setTelegramSaveBusy(false) + } + }, [apiFetch, isLive, tg.chatId, tg.token, tg.messageThreadId]) + + const testTelegram = useCallback(async () => { + if (!isLive) return + const threadRaw = tg.messageThreadId.trim() + const messageThreadId = + threadRaw ? Number.parseInt(threadRaw, 10) : undefined + await apiFetch("/api/alerts/telegram/test", { + method: "POST", + body: JSON.stringify({ + token: tg.token.trim() || undefined, + chatId: tg.chatId.trim() || undefined, + ...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1 + ? { messageThreadId } + : {}), + }), + }) + }, [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId]) + + const testRuleTelegramFromForm = useCallback( + async (p: RuleTelegramTestPayload) => { + if (!isLive) return + const threadRaw = tg.messageThreadId.trim() + const messageThreadId = + threadRaw && Number.isFinite(Number.parseInt(threadRaw, 10)) + ? Number.parseInt(threadRaw, 10) + : undefined + await apiFetch("/api/alerts/telegram/test", { + method: "POST", + body: JSON.stringify({ + token: tg.token.trim() || undefined, + chatId: p.ruleChatId.trim() || tg.chatId.trim() || undefined, + ...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1 + ? { messageThreadId } + : {}), + rulePreview: { + name: p.name, + targets: p.targets, + conditionLine: p.conditionLine, + severity: p.severity, + cooldown: p.cooldown, + }, + }), + }) + }, + [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId], + ) + + const serverNamesForPresets = useMemo(() => { + if (isLive && meta?.servers?.length) return meta.servers + return TYPE_TARGETS.server + }, [isLive, meta]) + + const applyPresetAppend = useCallback( + (presetId: string) => { + const def = ALERT_PRESETS.find((p) => p.id === presetId) + if (!def) return + const built = def.build(serverNamesForPresets) + if (built.length === 0) return + const newRows: AlertRule[] = built.map((b) => ({ + ...b, + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + enabled: true, + lastFired: null, + })) + void flushAlertsConfig([...rules, ...newRows], groups) + }, + [serverNamesForPresets, rules, groups, flushAlertsConfig], + ) + + const applyPresetReplace = useCallback( + (presetId: string) => { + if (typeof window !== "undefined" && !window.confirm("Заменить все текущие правила выбранным пресетом?")) { + return + } + const def = ALERT_PRESETS.find((p) => p.id === presetId) + if (!def) return + const built = def.build(serverNamesForPresets) + const newRows: AlertRule[] = built.map((b) => ({ + ...b, + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + enabled: true, + lastFired: null, + })) + void flushAlertsConfig(newRows, groups) + }, + [serverNamesForPresets, groups, flushAlertsConfig], + ) + + const addGroup = useCallback(() => { + const g: AlertGroup = { + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `grp-${Date.now()}`, + name: "Новая группа", + combineMode: "any", + enabled: true, + cooldownOverride: null, + } + void flushAlertsConfig(rules, [...groups, g]) + }, [rules, groups, flushAlertsConfig]) + + const patchGroup = useCallback( + (id: string, patch: Partial) => { + void flushAlertsConfig( + rules, + groups.map((x) => (x.id === id ? { ...x, ...patch } : x)), + ) + }, + [rules, groups, flushAlertsConfig], + ) + + const removeGroup = useCallback( + (id: string) => { + const nextRules = rules.map((r) => (r.groupId === id ? { ...r, groupId: null } : r)) + void flushAlertsConfig(nextRules, groups.filter((g) => g.id !== id)) + }, + [rules, groups, flushAlertsConfig], + ) const total = rules.length const active = rules.filter(r => r.enabled).length @@ -659,26 +2764,79 @@ export default function AlertsPage() { const filteredRules = useMemo(() => rules.filter(r => { if (sevFilter !== "all" && r.severity !== sevFilter) return false if (onlyActive && !r.enabled) return false - if (search && !r.name.toLowerCase().includes(search.toLowerCase()) && - !r.target.toLowerCase().includes(search.toLowerCase())) return false + const blob = + `${r.name} ${r.target} ${r.targets.join(" ")} ${r.condition} ${r.conditions.join(" ")}`.toLowerCase() + if (search && !blob.includes(search.toLowerCase())) return false return true }), [rules, sevFilter, onlyActive, search]) - const handleToggle = (id: string, enabled: boolean) => - setRules(rs => rs.map(r => r.id === id ? { ...r, enabled } : r)) - - const handleDelete = (id: string) => - setRules(rs => rs.filter(r => r.id !== id)) - - const handleAdd = (rule: Omit) => { - setRules(rs => [...rs, { - ...rule, - id: `r${Date.now()}`, - enabled: true, - lastFired: null, - }]) + const handleToggle = (id: string, enabled: boolean) => { + const next = rules.map(r => r.id === id ? { ...r, enabled } : r) + if (isLive) void flushAlertsConfig(next, groups) + else { + setRules(next) + } } + const handleDelete = (id: string) => { + const next = rules.filter(r => r.id !== id) + if (isLive) void flushAlertsConfig(next, groups) + else { + setRules(next) + } + } + + const closeRuleSheet = useCallback(() => { + setSheetOpen(false) + setSheetEditId(null) + setSheetSnap(null) + }, []) + + const openRuleSheetAdd = useCallback(() => { + setSheetEditId(null) + setSheetSnap(null) + setSheetKey((k) => k + 1) + setSheetOpen(true) + }, []) + + const openRuleSheetEdit = useCallback((id: string) => { + const r = rules.find((x) => x.id === id) + if (!r) return + setSheetSnap(ruleToFormSnapshot(r)) + setSheetEditId(id) + setSheetKey((k) => k + 1) + setSheetOpen(true) + }, [rules]) + + const handleRuleSheetSave = useCallback( + (payload: Omit, editId: string | null) => { + if (editId) { + const cur = rules.find((x) => x.id === editId) + if (!cur) return + const next = rules.map((x) => + x.id === editId ? { ...x, ...payload, id: editId, enabled: cur.enabled, lastFired: cur.lastFired } : x, + ) + if (isLive) void flushAlertsConfig(next, groups) + else { + setRules(next) + } + return + } + const row: AlertRule = { + ...payload, + id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `r${Date.now()}`, + enabled: true, + lastFired: null, + } + const next = [...rules, row] + if (isLive) void flushAlertsConfig(next, groups) + else { + setRules(next) + } + }, + [rules, groups, isLive, flushAlertsConfig], + ) + // Summary chip click handler const handleChip = (sev: SeverityFilter) => setSevFilter(f => f === sev ? "all" : sev) @@ -691,10 +2849,12 @@ export default function AlertsPage() { crumbs={[{ label: "Система" }, { label: "Оповещения" }]} actions={ <> - - @@ -704,6 +2864,57 @@ export default function AlertsPage() {
+ {isLive && backendStatus === false && ( +

+ Бэкенд недоступен по текущему URL. Проверьте адрес в настройках источника данных или переключитесь в режим демо. +

+ )} + + {!isLive && ( +

+ Режим демо: показаны примерные данные без сохранения. Включите «Живые данные» в источнике данных для работы с БД. +

+ )} + + {loadError && ( +

+ {loadError} +

+ )} + + + + + Подсказки: формат целей и «восстановился» + + +

+ Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие + срабатывает только если цепочка состояний совпадает с текстом правила. +

+
    +
  • + RTT / потери — в объектах укажите ключ пробы + как в мониторинге: имя пробы → target. +
  • +
  • + Восстановился — нужна история: после падения + в последних сэмплах виден возврат в online (REST и метрики ресурса объединяются). Один текущий + online без прошлого offline часто не даёт hit. +
  • +
  • + Перешёл в offline — только на ребре (предыдущий + сэмпл был online). Пока сервер остаётся offline, повторные уведомления не шлются. +
  • +
  • + Стабильность / cooldown — задержка перед + Telegram и пауза между повторными отправками. В разделе «Сбор данных» в деталях прогона «Оповещения» + есть таблица по каждому правилу (hit, стабильность, cooldown, Telegram, причина блока). +
  • +
+
+
+ {/* ── summary chip bar ── */}
@@ -750,6 +2961,168 @@ export default function AlertsPage() {
+ {/* ── presets + groups (свёрнуто по умолчанию) ── */} + + + +
+ Пресеты и группы ANY / ALL + + Массовое добавление правил и объединение нескольких правил в одно уведомление + +
+ + {groups.length ? `${groups.length} гр.` : "групп нет"} + +
+ +
+
+ + + Пресеты + + +

+ Быстро добавить набор правил из каталога серверов (демо или live meta). +

+ {ALERT_PRESETS.map((p) => ( +
+

{p.title}

+

{p.description}

+
+ + +
+
+ ))} +
+
+ + + + Группы ANY / ALL + + + + {groups.length === 0 ? ( +

+ Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL. +

+ ) : ( + groups.map((g) => ( +
+
+ patchGroup(g.id, { name: e.target.value })} + className="h-8 text-xs flex-1 min-w-[140px]" + aria-label="Имя группы" + /> + + +
+
+ Режим + + Cooldown группы + +
+
+ )) + )} +
+
+
+
+
+
+ {/* ── main layout ── */}
@@ -757,11 +3130,16 @@ export default function AlertsPage() {
- Правила оповещения + + Правила оповещения + {rulesSaveBusy && } + setSearch(e.target.value)} + autoComplete="off" + name="alert-rules-search" className="h-7 text-xs max-w-[200px]" />
@@ -781,6 +3159,8 @@ export default function AlertsPage() { rule={r} onToggle={handleToggle} onDelete={handleDelete} + onEdit={openRuleSheetEdit} + interactionsDisabled={rulesSaveBusy} /> ))}
@@ -794,7 +3174,8 @@ export default function AlertsPage() { {sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`} @@ -804,7 +3185,14 @@ export default function AlertsPage() { {/* ── right sidebar ── */}
- +
@@ -812,9 +3200,19 @@ export default function AlertsPage() {
setSheetOpen(false)} - onSave={handleAdd} + onClose={closeRuleSheet} + onSave={handleRuleSheetSave} + typeTargets={typeTargets} + isLive={isLive} + serversCatalog={serversCatalog} + greTunnelCatalog={greTunnelCatalog} + groups={groups} + initialForm={sheetSnap} + editRuleId={sheetEditId} + onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined} + testTelegramDisabled={isLive && backendStatus === false} />
) diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx index aa2ca2a..27c0dc1 100644 --- a/app/(main)/asns/page.tsx +++ b/app/(main)/asns/page.tsx @@ -57,8 +57,8 @@ export default function AsnsPage() { {d.org}, - }, - { - key: "country", - label: "Страна", - render: (d) => {d.country}, + label: "Имя / организация", + render: (d) => ( + + {d.org} + + ), }, { key: "prefixes", diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx index 214d0db..51a8fc3 100644 --- a/app/(main)/communities/page.tsx +++ b/app/(main)/communities/page.tsx @@ -279,13 +279,13 @@ export default function CommunitiesPage() { {ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""} - - {c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"} + + {c.prefixCount.toLocaleString("ru-RU")} - +
- {c.serverCount} + {c.serverCount.toLocaleString("ru-RU")}
@@ -337,8 +337,8 @@ export default function CommunitiesPage() { {[ ["Тип", TYPE_LABELS[selected.type]], ["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`], - ["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"], - ["Серверов", String(selected.serverCount)], + ["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")], + ["Серверов", selected.serverCount.toLocaleString("ru-RU")], ].map(([k, v]) => (
{k} diff --git a/app/(main)/data-collection/page.tsx b/app/(main)/data-collection/page.tsx new file mode 100644 index 0000000..d0e99a0 --- /dev/null +++ b/app/(main)/data-collection/page.tsx @@ -0,0 +1,1252 @@ +"use client" + +import Link from "next/link" +import { useCallback, useEffect, useMemo, useState } from "react" +import { PageHeader } from "@/components/page-header" +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { useDataSource } from "@/lib/data-source" +import { + SCHEDULER_JOB_DESCRIPTIONS, + SCHEDULER_JOB_KEYS, + SCHEDULER_JOB_LABELS, + type CollectorSettingsDto, + type SchedulerJobStatusDto, + type SchedulerRunRowDto, + type UptimeSettingsDto, +} from "@/lib/scheduler-settings" +import { + parseSchedulerRunSnapshot, + type AlertEngineRuleDiagSnapshot, + type AlertEngineRunSnapshot, + type GreBgpSnapshotRunSnapshot, + type PingRunSnapshot, + type ResourcesRunSnapshot, + type SchedulerRunSnapshot, + type ServersRestPingRunSnapshot, + type SpeedScheduledRunSnapshot, + type TrafficRunSnapshot, +} from "@/lib/scheduler-run-snapshot" +import { cn } from "@/lib/utils" +import { + AlertCircleIcon, + CalendarClockIcon, + CheckCircleIcon, + ChevronDownIcon, + DatabaseIcon, + LoaderCircleIcon, + RefreshCwIcon, + XCircleIcon, +} from "lucide-react" + +function makeApiFetch(backendUrl: string) { + return async function apiFetch(path: string, init?: RequestInit): Promise { + const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} + const res = await fetch(backendUrl.replace(/\/$/, "") + path, { + ...init, + headers: { ...headers, ...(init?.headers ?? {}) }, + }) + if (!res.ok) { + let message = res.statusText + try { + const err = (await res.json()) as { error?: string } + message = err.error ?? message + } catch { + const text = await res.text().catch(() => "") + if (text) message = text + } + throw new Error(message || "Ошибка запроса") + } + return res.json() as Promise + } +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +function fmtMs(ms: number): string { + if (ms < 1000) return `${ms} мс` + const s = ms / 1000 + return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с` +} + +function fmtUptimeSec(sec: number): string { + if (sec <= 0) return "—" + const d = Math.floor(sec / 86400) + const h = Math.floor((sec % 86400) / 3600) + const m = Math.floor((sec % 3600) / 60) + if (d > 0) return `${d}д ${h}ч` + if (h > 0) return `${h}ч ${m}м` + return `${m}м` +} + +function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) { + if (snap.job === "traffic") { + const t = snap as TrafficRunSnapshot + return ( +
+ {t.skipped ? ( +

Прогон пропущен: предыдущий сбор трафика ещё выполнялся.

+ ) : null} + {t.fatalError ? ( +

Критическая ошибка: {t.fatalError}

+ ) : null} +

+ Сэмплы на момент {new Date(t.sampledAt).toLocaleString("ru-RU")} +

+
+ + + + + + + + + + + + + + {t.servers.map((s) => ( + + + + + + + + + + ))} + +
СерверХостРезультатIFΣ RXΣ TXОшибка
{s.name}{s.host} + {s.ok ? ( + + ok + + ) : ( + + ошибка + + )} + {s.interfaces ?? "—"}{s.sumRxMbps != null ? `${s.sumRxMbps} Мбит/с` : "—"}{s.sumTxMbps != null ? `${s.sumTxMbps} Мбит/с` : "—"}{s.error ?? "—"}
+
+
+ ) + } + if (snap.job === "uptime_resources") { + const u = snap as ResourcesRunSnapshot + return ( +
+ {u.skipped ? ( +

Прогон пропущен: сбор ресурсов уже выполняется.

+ ) : null} + {u.fatalError ?

Критическая ошибка: {u.fatalError}

: null} +

+ Сэмплы на {new Date(u.sampledAt).toLocaleString("ru-RU")} +

+
+ + + + + + + + + + + + + + + + {u.servers.map((s) => ( + + + + + + + + + + + + ))} + +
СерверСтатусCPU %Память% RAMДиск своб.UptimeПлата / ROSОшибка
+ {s.name} + {s.host} + + + {s.status} + + {s.cpuLoadPct ?? "—"} + {s.memUsedMb != null && s.memTotalMb != null ? `${s.memUsedMb} / ${s.memTotalMb} МБ` : "—"} + {s.memUsedPct != null ? `${s.memUsedPct}%` : "—"} + {s.diskFreeMb != null && s.diskTotalMb != null ? `${s.diskFreeMb} / ${s.diskTotalMb} МБ` : "—"} + {s.uptimeSeconds != null ? fmtUptimeSec(s.uptimeSeconds) : "—"} + {s.boardName || "—"} + {s.rosVersion || ""} + {s.error ?? "—"}
+
+
+ ) + } + if (snap.job === "servers_rest_ping") { + const s = snap as ServersRestPingRunSnapshot + return ( +
+ {s.skipped ? ( +

Прогон пропущен: предыдущая проверка API ещё выполнялась.

+ ) : null} + {s.fatalError ?

Критическая ошибка: {s.fatalError}

: null} +

+ GET /system/identity на{" "} + {new Date(s.sampledAt).toLocaleString("ru-RU")} +

+
+ + + + + + + + + + + + {s.servers.map((row) => ( + + + + + + + + ))} + +
СерверХостРезультатRTT RESTОшибка
{row.name}{row.host} + {row.ok ? ( + + ok + + ) : ( + + недоступен + + )} + + {row.latencyMs != null ? `${row.latencyMs} мс` : "—"} + {row.error ?? "—"}
+
+
+ ) + } + if (snap.job === "uptime_ping") { + const p = snap as PingRunSnapshot + return ( +
+ {p.skipped ? ( +

Прогон пропущен: сбор ping уже выполняется.

+ ) : null} + {p.fatalError ?

Критическая ошибка: {p.fatalError}

: null} + {p.skippedByInterval != null && p.skippedByInterval > 0 ? ( +

Пропущено по интервалу проб: {p.skippedByInterval}

+ ) : null} +

+ Сэмплы на {new Date(p.sampledAt).toLocaleString("ru-RU")} — только пробы, для которых записан замер в этом тике +

+
+ + + + + + + + + + + + + + + {p.probes.map((x) => ( + + + + + + + + + + + ))} + +
ПробаЦельИсточникIFRTTLossСтатусОшибка
+ {x.name} + {x.probeId} + {x.target}{x.srcServerName}{x.srcInterface || "—"}{x.rttMs != null ? `${x.rttMs} мс` : "—"}{x.lossPct}% + {x.status} + {x.error ?? "—"}
+
+
+ ) + } + if (snap.job === "uptime_speed") { + const s = snap as SpeedScheduledRunSnapshot + return ( +
+

+ Прогоны speed на {new Date(s.sampledAt).toLocaleString("ru-RU")} — по очереди для каждой включённой пробы +

+
+ + + + + + + + + + + + + + + + + {s.runs.map((x) => ( + + + + + + + + + + + + + ))} + +
ПробаМаршрутИнтерфейсыПротоколTXRXPing RTTLossРезультатОшибка
{x.probeId} + {x.srcServerName} {x.dstServerName} + + {x.srcInterface || "—"} + {x.dstInterface || "—"} + {x.protocol} / {x.direction} / {x.durationSec}s{x.txAvgMbps != null ? `${Number(x.txAvgMbps).toFixed(1)}` : "—"}{x.rxAvgMbps != null ? `${Number(x.rxAvgMbps).toFixed(1)}` : "—"}{x.pingRttMs != null ? `${x.pingRttMs} мс` : "—"}{x.pingLossPct != null ? `${x.pingLossPct}%` : "—"} + {x.ok ? ( + ok + ) : ( + ошибка + )} + + {x.error ?? ""} + {x.pingError ? ( + ping: {x.pingError} + ) : null} +
+
+
+ ) + } + if (snap.job === "gre_bgp") { + const g = snap as GreBgpSnapshotRunSnapshot + return ( +
+ {g.skipped ? ( +

Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.

+ ) : null} + {g.fatalError ?

Критическая ошибка: {g.fatalError}

: null} +

+ Запись в SQLite на{" "} + {new Date(g.sampledAt).toLocaleString("ru-RU")} + {" "}— строки GRE и BGP для движка оповещений. +

+
+
+
Строк GRE
+
{g.greWritten}
+
+
+
Строк BGP
+
{g.bgpWritten}
+
+
+ {g.errors?.length ? ( +
+ {g.errors.map((e, i) => ( +

+ {e} +

+ ))} +
+ ) : null} +
+ ) + } + if (snap.job === "alert_engine") { + const a = snap as AlertEngineRunSnapshot + const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => { + switch (t) { + case "problem": + return "проблема" + case "recovery": + return "восстановление" + case "neutral": + return "нейтрально" + default: + return "—" + } + } + const blockedRu = (b: AlertEngineRuleDiagSnapshot["blocked"]) => { + switch (b) { + case "no_hit": + return "условие не выполнено" + case "stability": + return "стабильность (confirmStabilitySec)" + case "cooldown": + return "cooldown" + case "no_telegram": + return "нет Telegram" + case "dedupe_positive": + return "дедуп восстановления" + case "in_group": + return "в группе (отдельно не шлём)" + default: + return "—" + } + } + return ( +
+

+ Снимок на{" "} + {new Date(a.sampledAt).toLocaleString("ru-RU")} + . Данные для правил берутся из SQLite после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.). +

+
+
+
Правил проверено
+
{a.rulesChecked}
+
+
+
Отправок (правила)
+
{a.standaloneFires}
+
+
+
Отправок (группы)
+
{a.groupFires}
+
+
+
Без Telegram
+
{a.skippedNoTelegram ? "да" : "нет"}
+
+
+ {a.errors?.length ? ( +
+ {a.errors.map((e, i) => ( +

+ {e} +

+ ))} +
+ ) : null} + {a.ruleDiag && a.ruleDiag.length > 0 ? ( +
+

По правилам (почему не ушло в Telegram)

+
+ + + + + + + + + + + + + + + {a.ruleDiag.map((d) => ( + + + + + + + + + + + ))} + +
ID правилаСработалоТип срабатыванияСтабильностьКулдаунTelegramСообщениеПричина блока
+ {d.ruleId} + {d.evalHit ? "Да" : "Нет"}{transitionRu(d.hitTransition)}{d.stabilityOk ? "Да" : "Нет"}{d.cooldownOk ? "Да" : "Нет"}{d.telegramOk ? "Да" : "Нет"} + {d.hitMessage ?? "—"} + {blockedRu(d.blocked)}
+
+
+ ) : null} +
+ ) + } + return null +} + +function RunRowDetail({ r }: { r: SchedulerRunRowDto }) { + const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey + const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" + const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) + return ( +
+
+
+
ID записи
+
{r.id}
+
+
+
Ключ задачи
+
{r.jobKey}
+
+
+
Название и назначение
+
+ {jobTitle} +

{jobDesc}

+
+
+
+
Старт
+
{new Date(r.startedAt).toLocaleString("ru-RU")}
+
+
+
Завершение
+
{new Date(r.finishedAt).toLocaleString("ru-RU")}
+
+
+
Длительность
+
+ {r.durationMs} мс + ({fmtMs(r.durationMs)}) +
+
+
+
Результат
+
+ + {r.status} + +
+
+
+ {r.error ? ( +
+

Текст ошибки

+
+            {r.error}
+          
+
+ ) : ( +

Ошибка не записывалась (статус ok).

+ )} + + + +
+

Результаты измерений

+ {snapshot ? ( + + ) : ( +

+ Снимок отсутствует (запись до обновления бекенда или задача без детализации). +

+ )} +
+
+ ) +} + +export default function DataCollectionPage() { + const { mode, backendUrl, backendStatus } = useDataSource() + const isLive = mode === "live" && backendStatus === true + const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) + + const [trafficCollector, setTrafficCollector] = useState(null) + const [serversApiCollector, setServersApiCollector] = useState(null) + const [uptimeCollector, setUptimeCollector] = useState(null) + const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") + const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") + const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") + const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15") + const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60") + const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14") + const [draftTrafficEnabled, setDraftTrafficEnabled] = useState(true) + const [draftServersApiEnabled, setDraftServersApiEnabled] = useState(false) + const [serversApiIntervalDraft, setServersApiIntervalDraft] = useState("120") + const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true) + const [draftPingEnabled, setDraftPingEnabled] = useState(true) + const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true) + const [schedulerRuns, setSchedulerRuns] = useState([]) + const [runFilterJobKey, setRunFilterJobKey] = useState("") + const [runNowJobKey, setRunNowJobKey] = useState(null) + const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false) + const [pageBusy, setPageBusy] = useState(false) + const [collectorError, setCollectorError] = useState(null) + const [openRunIds, setOpenRunIds] = useState>(() => new Set()) + + const toggleRunOpen = useCallback((id: string, open: boolean) => { + setOpenRunIds((prev) => { + const next = new Set(prev) + if (open) next.add(id) + else next.delete(id) + return next + }) + }, []) + + const schedulerJobsByKey = useMemo(() => { + const jobs = uptimeCollector?.scheduler?.jobs ?? [] + return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record + }, [uptimeCollector?.scheduler?.jobs]) + + const loadCollectors = useCallback(async () => { + if (!isLive) return + setCollectorError(null) + setPageBusy(true) + try { + const runsQuery = + runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) + ? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}` + : "?limit=80" + const [traffic, serversApi, uptime, runsRes] = await Promise.all([ + apiFetch("/api/traffic/settings"), + apiFetch("/api/servers-api-ping/settings"), + apiFetch("/api/uptime/settings"), + apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`), + ]) + setTrafficCollector(traffic) + setServersApiCollector(serversApi) + setUptimeCollector(uptime) + setSchedulerRuns(runsRes.runs ?? []) + setTrafficIntervalDraft(String(traffic.intervalSec)) + setTrafficRetentionDraft(String(traffic.retentionDays)) + setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300)) + setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15)) + setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60)) + setUptimeRetentionDraft(String(uptime.retentionDays)) + setDraftTrafficEnabled(!!traffic.enabled) + setDraftServersApiEnabled(!!serversApi.enabled) + setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120)) + setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled)) + setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled)) + setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled)) + } catch (e) { + setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") + } finally { + setPageBusy(false) + } + }, [apiFetch, isLive, runFilterJobKey]) + + useEffect(() => { + if (!isLive) { + setTrafficCollector(null) + setServersApiCollector(null) + setUptimeCollector(null) + setSchedulerRuns([]) + return + } + void loadCollectors() + }, [isLive, loadCollectors]) + + useEffect(() => { + setOpenRunIds(new Set()) + }, [runFilterJobKey]) + + const handleRefreshScheduler = async () => { + if (!isLive) return + setCollectorError(null) + try { + await apiFetch("/api/scheduler/refresh", { method: "POST" }) + await loadCollectors() + } catch (e) { + setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик") + } + } + + const enabledJobsCount = useMemo(() => { + let n = draftTrafficEnabled ? 1 : 0 + if (draftServersApiEnabled) n += 1 + if (draftResourcesEnabled) n += 1 + if (draftPingEnabled) n += 1 + if (draftSpeedEnabled) n += 1 + return n + }, [ + draftPingEnabled, + draftResourcesEnabled, + draftServersApiEnabled, + draftSpeedEnabled, + draftTrafficEnabled, + ]) + + const schedulerJobCount = SCHEDULER_JOB_KEYS.length + + const runningJobsCount = useMemo( + () => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length, + [uptimeCollector?.scheduler?.jobs], + ) + + const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns]) + + const stats = useMemo( + () => [ + { + label: "Включено задач", + value: `${enabledJobsCount} / ${schedulerJobCount}`, + sub: "По переключателям на этой странице (до сохранения)", + icon: , + }, + { + label: "Сейчас выполняется", + value: String(runningJobsCount), + sub: "Фоновые прогоны планировщика", + icon: , + }, + { + label: "Трафик — последний сбор", + value: trafficCollector?.lastCollectedAt + ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" }) + : "—", + sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", + icon: trafficCollector?.lastError ? ( + + ) : ( + + ), + }, + { + label: "Журнал (в списке)", + value: String(schedulerRuns.length), + sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет", + icon: , + }, + ], + [ + enabledJobsCount, + schedulerJobCount, + runningJobsCount, + trafficCollector?.lastCollectedAt, + trafficCollector?.lastDurationMs, + trafficCollector?.lastError, + schedulerRuns.length, + errorRunsInView, + ], + ) + + return ( +
+ + + Настройки + + + +
+ } + /> + +
+
+ {!isLive && ( + + + Нужен live-режим + + Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках. + + + + + Открыть настройки + + + + )} + + {isLive && collectorError && ( + + + +

{collectorError}

+
+
+ )} + + {isLive && ( + <> +
+ {stats.map((s) => ( + + +
+

{s.label}

+

{s.value}

+

{s.sub}

+
+
{s.icon}
+
+
+ ))} +
+ + + + Планировщик сбора данных + + Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры. + + + +
+ + + + + + + + + + + + + {SCHEDULER_JOB_KEYS.map((jobKey) => { + const j = schedulerJobsByKey[jobKey] + const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" + const en = fixedSchedule + ? Boolean(j?.enabled ?? true) + : jobKey === "traffic" + ? draftTrafficEnabled + : jobKey === "servers_rest_ping" + ? draftServersApiEnabled + : jobKey === "uptime_resources" + ? draftResourcesEnabled + : jobKey === "uptime_ping" + ? draftPingEnabled + : draftSpeedEnabled + const iv = fixedSchedule + ? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) + : jobKey === "traffic" + ? trafficIntervalDraft + : jobKey === "servers_rest_ping" + ? serversApiIntervalDraft + : jobKey === "uptime_resources" + ? uptimeResourceIntervalDraft + : jobKey === "uptime_ping" + ? uptimeIntervalDraft + : uptimeSpeedIntervalDraft + const setIv = fixedSchedule + ? () => {} + : jobKey === "traffic" + ? setTrafficIntervalDraft + : jobKey === "servers_rest_ping" + ? setServersApiIntervalDraft + : jobKey === "uptime_resources" + ? setUptimeResourceIntervalDraft + : jobKey === "uptime_ping" + ? setUptimeIntervalDraft + : setUptimeSpeedIntervalDraft + const defSec = fixedSchedule + ? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) + : jobKey === "traffic" + ? 30 + : jobKey === "servers_rest_ping" + ? 120 + : jobKey === "uptime_resources" + ? 300 + : jobKey === "uptime_ping" + ? 15 + : 60 + return ( + + + + + + + + + ) + })} + +
ЗадачаВклИнтервал (с)Последний прогонСтатусСейчас
+ {SCHEDULER_JOB_LABELS[jobKey] ?? jobKey} +

+ {SCHEDULER_JOB_DESCRIPTIONS[jobKey]} +

+

{jobKey}

+
+ + { + if (fixedSchedule) return + if (jobKey === "traffic") setDraftTrafficEnabled(v) + else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v) + else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v) + else if (jobKey === "uptime_ping") setDraftPingEnabled(v) + else setDraftSpeedEnabled(v) + }} + /> + + + setIv(e.target.value)} + className="h-8 text-sm tabular-nums" + inputMode="numeric" + readOnly={fixedSchedule} + disabled={!en && !fixedSchedule} + placeholder={String(defSec)} + /> + + {j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"} + {j?.lastDurationMs != null && ( + {j.lastDurationMs} мс + )} + +
+ {j?.running ? ( + + выполняется + + ) : null} + {j?.lastStatus ? ( + + {j.lastStatus} + + ) : null} + {j?.lastError ? ( + + {j.lastError} + + ) : null} +
+
+ +
+
+ +
+
+
+

Хранение сэмплов трафика (дней)

+ setTrafficRetentionDraft(e.target.value)} + className="h-8 text-sm" + inputMode="numeric" + /> +
+
+

Хранение сэмплов uptime (дней)

+ setUptimeRetentionDraft(e.target.value)} + className="h-8 text-sm" + inputMode="numeric" + /> +
+
+

+ Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице). +

+ +
+

+ Трафик — последний сбор:{" "} + {trafficCollector?.lastCollectedAt + ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") + : "—"}{" "} + · {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"} + {trafficCollector?.collectorRunning ? ( + + коллектор занят + + ) : null} +

+

+ {trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"} +

+

+ REST API (серверы):{" "} + {serversApiCollector?.lastCollectedAt + ? new Date(serversApiCollector.lastCollectedAt).toLocaleString("ru-RU") + : "—"}{" "} + · {serversApiCollector?.lastDurationMs != null ? `${serversApiCollector.lastDurationMs} мс` : "—"} + {serversApiCollector?.collectorRunning ? ( + + проверка занята + + ) : null} +

+

+ {serversApiCollector?.lastError + ? `REST API (серверы): ${serversApiCollector.lastError}` + : "REST API (серверы): ошибок нет"} +

+

+ Uptime (агрегат в настройках):{" "} + {uptimeCollector?.lastCollectedAt + ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") + : "—"}{" "} + · {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"} +

+

+ {uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"} +

+
+
+
+
+ + + +
+ Журнал прогонов + + SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки. + +
+
+ + +
+
+ + {schedulerRuns.length === 0 ? ( +

Пока нет прогонов

+ ) : ( +
+ {schedulerRuns.map((r) => ( + toggleRunOpen(r.id, open)} + > + + +
+
+ {SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey} + {r.jobKey} +
+ + {new Date(r.finishedAt).toLocaleString("ru-RU")} + +
+ {r.durationMs} мс + + {r.status} + +
+
+
+ +
+
+ +
+
+
+
+ ))} +
+ )} +
+
+ + )} +
+
+
+ ) +} diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index b3e9d01..f92d861 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -27,12 +27,17 @@ import { import { collectGreEndpointHostnames, greResolvedMapFromApi, + isIpv4String, + normalizeGreEndpointAddr, } from "@/lib/gre-endpoint-resolve" import { assignSpeedProbesToGreTunnels, + assignSpeedProbesToWanJhEdges, + canonicalGreServerPairKey, greOuterSummaryLine, ifaceNameForGreOuterIp, mergeGreMetricsWithSpeedProbe, + wanJhEdgeMapKey, type GreSpeedProbeSnapshot, } from "@/lib/map-gre-speed-probe" import { Button } from "@/components/ui/button" @@ -318,6 +323,12 @@ function edgeBadgePosition( type FilterKey = "all" | "online" | "degraded" | "offline" | "jump-host" | "exit-node" | "home-router" +function greEdgeKey(e: GreMapEdge): string { + const la = (e.tunnel.localAddress ?? "").trim() + const ra = (e.tunnel.remoteAddress ?? "").trim() + return `${e.fromServer.id}|${e.toServer.id}|${e.tunnel.id}|${la}|${ra}` +} + // ─── SVG sub-components ─────────────────────────────────────────────────────── /** Бейдж на ребре WAN → JumpHost (не GRE): сумма latency домашнего узла и JH — те же поля, что в разделе Серверы. */ @@ -697,7 +708,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, {/* simplified edges */} {greEdges.map((e) => ( - ))} {/* WAN edges */} @@ -831,7 +842,18 @@ export default function NetworkMapPage() { lastPingRttMs: p.lastPingRttMs, })), ) - const hostnames = collectGreEndpointHostnames(mappedTunnels) + const hostSet = new Set(collectGreEndpointHostnames(mappedTunnels)) + for (const row of rows) { + if (!Array.isArray(row.wanUplinks)) continue + for (const w of row.wanUplinks) { + const raw = (w.ip ?? "").trim() + if (!raw) continue + const n = normalizeGreEndpointAddr(raw) + if (!n || n === "0.0.0.0") continue + if (!isIpv4String(n)) hostSet.add(n.toLowerCase()) + } + } + const hostnames = [...hostSet] if (hostnames.length > 0) { try { const res = await apiFetch<{ results: Record }>( @@ -900,6 +922,10 @@ export default function NetworkMapPage() { try { const k = "mm-network-map-layout-rev" if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) { + // Важно: без явного сброса старые drag-координаты остаются в state + // и перекрывают новый авто-лейаут на live-данных. + setNodePositions({}) + setSatPositions({}) sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION)) } } catch { @@ -957,12 +983,21 @@ export default function NetworkMapPage() { [mapGreTunnels, mapServers, nodePosById, effectiveSatPos, greResolvedMap], ) - /** WAN→JH: не показывать PingBadge, если на том же дом+WAN+JH уже есть GRE с метрик-бейджем. */ + /** Speed-пробы на сегментах WAN→JH (тот же формат бейджа, что на GRE JH↔EN). */ + const speedProbeByWanJhKey = useMemo( + () => assignSpeedProbesToWanJhEdges(wanJhEdges, mapServers, speedProbes), + [wanJhEdges, mapServers, speedProbes], + ) + + /** + * WAN→JH: не дублировать линию спутник→пир, если уже есть GRE с того же WAN к тому же узлу + * (JH или exit — иначе «второй» бейдж speed посередине сегмента). + */ const suppressWanJhPingBadge = useMemo(() => { const s = new Set() for (const g of greEdges) { if (g.fromServer.type !== "home-router") continue - if (g.toServer.type !== "jump-host") continue + if (g.toServer.type === "home-router") continue const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) if (widx == null) continue s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) @@ -986,7 +1021,7 @@ export default function NetworkMapPage() { () => assignSpeedProbesToGreTunnels( greEdges.map((e) => ({ - tunnel: e.tunnel, + tunnel: { ...e.tunnel, id: greEdgeKey(e) }, fromServer: e.fromServer, toServer: e.toServer, })), @@ -996,14 +1031,77 @@ export default function NetworkMapPage() { [greEdges, speedProbes, greResolvedMap], ) + /** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */ + const suppressWanJhSpeedBadgeDuplicate = useMemo(() => { + const s = new Set() + for (const g of greEdges) { + if (g.fromServer.type !== "home-router") continue + if (g.toServer.type === "home-router") continue + const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) + if (widx == null) continue + const spGre = speedProbeByTunnelId.get(greEdgeKey(g)) + const merged = mergeGreMetricsWithSpeedProbe(spGre, greTunnelProbe(g.tunnel)) + if (!merged.hasSpeedMonitor) continue + s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) + } + return s + }, [greEdges, speedProbeByTunnelId, greResolvedMap]) + + /** WAN→JH: скрываем саму линию, если тот же отрезок уже отрисован GRE (одна связь на карте). */ + const suppressWanJhLineDuplicate = useMemo(() => { + const s = new Set() + for (const g of greEdges) { + if (g.fromServer.type !== "home-router") continue + if (g.toServer.type === "home-router") continue + const widx = greSourceWanIndexOnMap(g.fromServer, g.tunnel, greResolvedMap) + if (widx == null) continue + s.add(`${g.fromServer.id}\t${widx}\t${g.toServer.id}`) + } + return s + }, [greEdges, greResolvedMap]) + + const visibleWanJhEdges = useMemo( + () => wanJhEdges.filter((e) => !suppressWanJhLineDuplicate.has(wanJhEdgeMapKey(e))), + [wanJhEdges, suppressWanJhLineDuplicate], + ) + + /** Один бейдж на совпадающий отрезок карты (два туннеля в БД HR→MSK и MSK→HR — одна линия). */ + const greTunnelIdsWithMapBadge = useMemo(() => { + const edgeByTunnel = new Map(greEdges.map((e) => [greEdgeKey(e), e] as const)) + const geomKey = (e: GreMapEdge) => { + const a = `${Math.round(e.from.x)},${Math.round(e.from.y)}` + const b = `${Math.round(e.to.x)},${Math.round(e.to.y)}` + return a <= b ? `${a}|${b}` : `${b}|${a}` + } + const rank = (tid: string): number => { + const e = edgeByTunnel.get(tid) + if (!e) return -1 + const sp = speedProbeByTunnelId.get(tid) + const m = mergeGreMetricsWithSpeedProbe(sp, greTunnelProbe(e.tunnel)) + let r = m.hasSpeedMonitor ? 1000 : 0 + r += m.pingMs ?? 0 + return r + } + const winners = new Map() + for (const e of greEdges) { + const key = geomKey(e) + const cur = winners.get(key) + const edgeId = greEdgeKey(e) + if (!cur || rank(edgeId) > rank(cur) || (rank(edgeId) === rank(cur) && edgeId.localeCompare(cur) < 0)) { + winners.set(key, edgeId) + } + } + return new Set(winners.values()) + }, [greEdges, speedProbeByTunnelId]) + /** Несколько GRE между одной парой узлов — смещаем бейджи по дуге и по нормали, без наложения. */ const greBadgeStaggerByTunnelId = useMemo(() => { const pairOrder = new Map() const out = new Map() for (const ge of greEdges) { - const k = `${ge.fromServer.id}:${ge.toServer.id}` + const k = canonicalGreServerPairKey(ge.fromServer, ge.toServer) const lane = pairOrder.get(k) ?? 0 - out.set(ge.tunnel.id, lane) + out.set(greEdgeKey(ge), lane) pairOrder.set(k, lane + 1) } return out @@ -1216,6 +1314,16 @@ export default function NetworkMapPage() { setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx) } + function openWanJhSpeedDetail(ev: React.MouseEvent, edge: WanJhEdge) { + ev.stopPropagation() + const home = mapServers.find((s) => s.id === edge.homeId) + if (!home) return + setSelectedGreEdge(null) + setSelected(home) + setSelWanIdx(edge.wanIdx) + setHoveredId(null) + } + const connectedTunnels = selected ? mapGreTunnels.filter((t) => { const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) @@ -1410,6 +1518,7 @@ export default function NetworkMapPage() { {/* ── GRE edges ── */} {greEdges.map((e, i) => { + const edgeId = greEdgeKey(e) const ts = TUNNEL_STYLE[e.tunnel.status] const fromN = nodeById[e.fromServer.id] const toN = nodeById[e.toServer.id] @@ -1419,12 +1528,12 @@ export default function NetworkMapPage() { !isVisible(fromN) && !isVisible(toN) const baseProbe = greTunnelProbe(e.tunnel) - const spGre = speedProbeByTunnelId.get(e.tunnel.id) + const spGre = speedProbeByTunnelId.get(edgeId) const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) const rttFromMonitor = spGre?.lastPingRttMs != null const throughputFromMonitor = spGre?.lastTxAvgMbps != null || spGre?.lastRxAvgMbps != null - const badgeLane = greBadgeStaggerByTunnelId.get(e.tunnel.id) ?? 0 + const badgeLane = greBadgeStaggerByTunnelId.get(edgeId) ?? 0 const tBadge = Math.min(0.78, Math.max(0.22, 0.34 + badgeLane * 0.052)) const normalPx = (badgeLane % 2 === 0 ? 1 : -1) * (16 + badgeLane * 22) @@ -1443,7 +1552,7 @@ export default function NetworkMapPage() { setSelWanIdx(null) } return ( - + )} - {showPingBadges && ( + {showPingBadges && greTunnelIdsWithMapBadge.has(edgeId) && ( { + {visibleWanJhEdges.map((edge, i) => { const jh = nodeById[edge.jhId] const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx] if (!jh || !satPos) return null @@ -1528,10 +1637,35 @@ export default function NetworkMapPage() { path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} /> )} - {showPingBadges && - !suppressWanJhPingBadge.has(`${edge.homeId}\t${edge.wanIdx}\t${edge.jhId}`) && ( - - )} + {showPingBadges && (() => { + const kj = wanJhEdgeMapKey(edge) + const sp = speedProbeByWanJhKey.get(kj) + const merged = mergeGreMetricsWithSpeedProbe(sp, { + pingMs: edge.pingMs, + dlMbps: null, + ulMbps: null, + }) + const hasMonitor = merged.hasSpeedMonitor + if (!hasMonitor && suppressWanJhPingBadge.has(kj)) return null + if (hasMonitor && suppressWanJhSpeedBadgeDuplicate.has(kj)) return null + if (hasMonitor) { + return ( + openWanJhSpeedDetail(ev, edge)} + rttFromMonitor={sp?.lastPingRttMs != null} + throughputFromMonitor={ + sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null + } + /> + ) + } + return + })()} ) })} @@ -1677,7 +1811,7 @@ export default function NetworkMapPage() { nodes={nodes} greEdges={greEdges} satPos={effectiveSatPos} - wanJhEdges={wanJhEdges} + wanJhEdges={visibleWanJhEdges} homeRouters={homeRouters} onClose={() => setShowMinimap(false)} onPan={(x, y) => setPan({ x, y })} @@ -1743,10 +1877,11 @@ export default function NetworkMapPage() { {(() => { const edge = selectedGreEdge const t = edge.tunnel + const selectedEdgeId = greEdgeKey(edge) const localWanName = ifaceNameForGreOuterIp(edge.fromServer, t.localAddress, greResolvedMap) const remoteWanName = ifaceNameForGreOuterIp(edge.toServer, t.remoteAddress, greResolvedMap) const baseProbe = greTunnelProbe(t) - const spGr = speedProbeByTunnelId.get(t.id) + const spGr = speedProbeByTunnelId.get(selectedEdgeId) const merged = mergeGreMetricsWithSpeedProbe(spGr, baseProbe) const rttMon = spGr?.lastPingRttMs != null const bwMon = spGr?.lastTxAvgMbps != null || spGr?.lastRxAvgMbps != null @@ -2039,16 +2174,20 @@ export default function NetworkMapPage() { : mapServers.find((s) => s.id === t.serverId) const fromServer = mapServers.find((s) => s.id === t.serverId) const toServer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) + const tunnelPanelKey = + fromServer && toServer + ? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}` + : `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}` const baseProbe = greTunnelProbe(t) const spGre = - fromServer && toServer ? speedProbeByTunnelId.get(t.id) : undefined + fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe) const pc = pingColor(merged.pingMs) const showMetrics = merged.pingMs != null || (merged.dlMbps != null && merged.ulMbps != null) return ( -
+
{t.name} diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index f41934d..bfd66ae 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -12,6 +12,7 @@ import { import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" +import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" // ─── types ──────────────────────────────────────────────────────────────────── @@ -119,6 +120,28 @@ interface BackendOspfAll { bfdSessions: BackendBfdSession[] } +function isRefInterfaceName(name: string): boolean { + return /^\(ref\s+\*.+\)$/.test(name.trim()) +} + +interface BackendOspfOptimizeResponse { + serverId: number + serverName: string + pingWeight: number + optimizedCount: number + applied: Array<{ interface: string; from: number; to: number }> + interfaces: Array<{ + id: string + interface: string + currentCost: number + optimalCost: number + score: number + pingMs: number + dlMbps: number + ulMbps: number + }> +} + // ─── backend → frontend mappers ─────────────────────────────────────────────── function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): OspfNeighbor { @@ -625,11 +648,25 @@ function NodeDetailPanel({ // ─── interfaces tab ─────────────────────────────────────────────────────────── -function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isLive: boolean }) { +function InterfacesTab({ + items: initialItems, + isLive, + filterServerId, + backendUrl, + onLiveDataRefresh, +}: { + items: OspfItem[] + isLive: boolean + filterServerId: string + backendUrl: string + onLiveDataRefresh: () => void +}) { const [items, setItems] = useState(initialItems) const [dragging, setDragging] = useState(null) const [dragOver, setDragOver] = useState(null) const [toast, setToast] = useState(null) + const [optimizing, setOptimizing] = useState(false) + const [liveOptimalCost, setLiveOptimalCost] = useState>({}) // Sync with live data when it changes useEffect(() => { @@ -660,17 +697,67 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL if (isLive) return {} const out: Record = {} grouped.forEach(router => { - router.areas.forEach(ag => { - const ranked = [...ag.items] - .map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 })) - .sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName)) - ranked.forEach(({ item, prob }, idx) => { out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob } }) + const ranked = router.areas + .flatMap(ag => ag.items) + .map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 })) + .sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName)) + + // В рамках одного роутера выдаём строго уникальные optimal cost. + ranked.forEach(({ item, prob }, idx) => { + out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob } }) }) return out }, [grouped, isLive]) const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost) + const canOptimizeLive = isLive && filterServerId !== "all" + const uniqueLiveFallbackOpt = useMemo(() => { + const out: Record = {} + const byRouter: Record = {} + items.forEach((item) => { + if (!byRouter[item.routerKey]) byRouter[item.routerKey] = [] + byRouter[item.routerKey].push(item) + }) + Object.values(byRouter).forEach((routerItems) => { + routerItems + .slice() + .sort((a, b) => a.cost - b.cost || a.interfaceName.localeCompare(b.interfaceName)) + .forEach((item, idx) => { + out[item.key] = (idx + 1) * COST_STEP + }) + }) + return out + }, [items]) + + async function handleLiveOptimize() { + if (!canOptimizeLive) { + showToast("Выберите конкретный сервер для оптимизации OSPF") + return + } + const ra = readStoredRouteOptimizerSettings() + setOptimizing(true) + try { + const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pingWeight: ra.pingWeight }), + }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const data = await r.json() as BackendOspfOptimizeResponse + const byKey: Record = {} + data.interfaces.forEach((row) => { + byKey[`${data.serverId}-${row.id}`] = row.optimalCost + }) + setLiveOptimalCost(byKey) + showToast(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`) + onLiveDataRefresh() + } catch (err) { + showToast(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`) + } finally { + setOptimizing(false) + } + } function onDrop(routerKey: string, area: string, targetKey: string) { if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return } @@ -704,6 +791,12 @@ function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isL Оптимизировать )} + {canOptimizeLive && ( + + )} {!isLive && (
- {activeTab === "interfaces" && } + {activeTab === "interfaces" && ( + setFetchTick(t => t + 1)} + /> + )} {activeTab === "neighbors" && ( ([]) const [expandedGroupKey, setExpandedGroupKey] = useState(null) const [opError, setOpError] = useState(null) + /** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */ + const [liveServerListReady, setLiveServerListReady] = useState(false) useEffect(() => { if (!isLive) { // eslint-disable-next-line react-hooks/set-state-in-effect + setLiveServerListReady(true) setServers(mockServers) setSelectedServerId(mockServers[0]?.id ?? "") setRows([]) return } + // eslint-disable-next-line react-hooks/set-state-in-effect + setLiveServerListReady(false) apiFetch("/api/servers") .then((data) => { const mapped = data.map(mapBackendServer) @@ -548,10 +553,13 @@ export default function RecursiveRoutesPage() { setServers([]) setSelectedServerId("") }) + .finally(() => { + setLiveServerListReady(true) + }) }, [isLive, apiFetch]) const loadRoutes = useCallback(async () => { - if (!isLive || !selectedServerId) return + if (!isLive || !liveServerListReady || !selectedServerId) return setOpError(null) setBusy("load") try { @@ -563,17 +571,17 @@ export default function RecursiveRoutesPage() { } finally { setBusy(null) } - }, [isLive, selectedServerId, apiFetch]) + }, [isLive, liveServerListReady, selectedServerId, apiFetch]) const loadGateways = useCallback(async () => { - if (!isLive || !selectedServerId) return + if (!isLive || !liveServerListReady || !selectedServerId) return try { const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`) setGatewayOptions(res.gateways) } catch { setGatewayOptions([]) } - }, [isLive, selectedServerId, apiFetch]) + }, [isLive, liveServerListReady, selectedServerId, apiFetch]) useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect diff --git a/app/(main)/route-optimizer/page.tsx b/app/(main)/route-optimizer/page.tsx index 38019e7..bdf8a37 100644 --- a/app/(main)/route-optimizer/page.tsx +++ b/app/(main)/route-optimizer/page.tsx @@ -22,11 +22,13 @@ import { type OptimizerData, type OptimizerSettings, type OptimizerApiServer, + type RouteOptimizerSpeedProbe, buildLiveOptimizerData, DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS, mapApiServersToTopology, readStoredRouteOptimizerSettings, ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, + calcRouteScore, } from "@/lib/route-optimizer-data" import { RefreshCwIcon, AlertCircleIcon, ArrowRightIcon, @@ -93,13 +95,6 @@ function jitter(base: number, range: number) { return Math.max(1, Math.round(base + (Math.random() - 0.5) * range * 2)) } -function calcScore(pingMs: number, dlMbps: number, ulMbps: number, pw: number) { - const pingScore = Math.max(0, 100 - pingMs * 0.6) - const speedScore = Math.min(100, (dlMbps + ulMbps) / 18) - const w = pw / 100 - return Math.round(w * pingScore + (1 - w) * speedScore) -} - function confidence(prob: number): "HIGH" | "MEDIUM" | "LOW" { return prob >= 55 ? "HIGH" : prob >= 30 ? "MEDIUM" : "LOW" } @@ -161,7 +156,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData { wanJhLegs.push({ wanId: wan.id, jhId: jh.id, pingMs: ping, dlMbps: dl, ulMbps: ul, loss: base.loss > 0 ? +(base.loss + (Math.random() - 0.5) * 0.5).toFixed(1) : 0, - score: calcScore(ping, dl, ul, pw), + score: calcRouteScore(ping, dl, ul, pw), }) } } @@ -177,7 +172,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData { const totalPing = hw.pingMs + je.pingMs const dl = Math.min(hw.dlMbps, je.dlMbps) const ul = Math.min(hw.ulMbps, je.ulMbps) - const score = calcScore(totalPing, dl, ul, pw) + const score = calcRouteScore(totalPing, dl, ul, pw) fullRoutes.push({ id: `${home.id}-${wan.id}-${jh.id}-${ex.id}`, homeId: home.id, wan, jh, exit: ex, hw, je, @@ -521,7 +516,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply - {recs.map(r => { + {recs.map((r, idx) => { const pinKey = `${homeId}::${r.community}` const isPinned = pinned.has(pinKey) const isApplied = applied.has(pinKey) @@ -529,7 +524,7 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply const canApply = r.shouldSwitch && !isPinned && !isApplied return ( - ([]) const [liveExitNodes, setLiveExitNodes] = useState([]) + const [liveServers, setLiveServers] = useState([]) const [showSettings, setShowSettings] = useState(false) const [pinned, setPinned] = useState>(new Set()) @@ -792,6 +788,34 @@ export default function RouteOptimizerPage() { const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst") const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled") const [selectedVrf, setSelectedVrf] = useState("main") + const [ospfServerId, setOspfServerId] = useState("") + const [ospfApplying, setOspfApplying] = useState(false) + const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null) + const [ospfApplyError, setOspfApplyError] = useState("") + const [ospfMeta, setOspfMeta] = useState<{ interfaces: number; areas: number; serverName: string } | null>(null) + const [ospfPreviewError, setOspfPreviewError] = useState("") + const [ospfPreview, setOspfPreview] = useState<{ + changedCount: number + interfacesTotal: number + interfaces: Array<{ + interface: string + currentCost: number + optimalCost: number + score: number + pingMs: number + dlMbps: number + ulMbps: number + }> + changes: Array<{ + interface: string + currentCost: number + optimalCost: number + score: number + pingMs: number + dlMbps: number + ulMbps: number + }> + } | null>(null) const load = useCallback(async (override?: OptimizerSettings) => { const s = override ?? settingsRef.current @@ -803,9 +827,13 @@ export default function RouteOptimizerPage() { setData(buildMockData(s)) setLiveJumpHosts([]) setLiveExitNodes([]) + setLiveServers([]) return } const rows = await apiFetch("/api/servers") + const enabledRows = rows.filter((r) => r.enabled) + setLiveServers(enabledRows) + setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? ""))) const { jumpHosts, exitNodes } = mapApiServersToTopology(rows) setLiveJumpHosts(jumpHosts) setLiveExitNodes(exitNodes) @@ -819,13 +847,21 @@ export default function RouteOptimizerPage() { } catch { rulesets = null } + let speedProbes: RouteOptimizerSpeedProbe[] = [] + try { + const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes") + speedProbes = sp.probes ?? [] + } catch { + speedProbes = [] + } - setData(buildLiveOptimizerData(rows, rulesets, s)) + setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes)) } catch (e) { setError(e instanceof Error ? e.message : "Ошибка загрузки данных") setData(null) setLiveJumpHosts([]) setLiveExitNodes([]) + setLiveServers([]) } finally { setLoading(false) } @@ -929,9 +965,104 @@ export default function RouteOptimizerPage() { ) } + async function applyOspfOptimization() { + if (!ospfServerId) return + setOspfApplying(true) + setOspfApplyError("") + setOspfApplyResult(null) + try { + const res = await apiFetch<{ serverName: string; optimizedCount: number }>( + `/api/servers/${ospfServerId}/ospf/optimize`, + { + method: "POST", + body: JSON.stringify({ pingWeight: settings.pingWeight }), + }, + ) + setOspfApplyResult({ + serverName: res.serverName, + optimizedCount: res.optimizedCount, + }) + await load() + setOspfPreview((prev) => (prev ? { ...prev, changedCount: 0, changes: [] } : prev)) + } catch (e) { + setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации") + } finally { + setOspfApplying(false) + } + } + const set = (k: K, v: OptimizerSettings[K]) => setSettings(prev => ({ ...prev, [k]: v })) + useEffect(() => { + if (!useLiveData || !ospfServerId) return + let cancelled = false + void apiFetch<{ + interfaces: Array<{ areaId: string; interface: string }> + instances: Array + neighbors: Array + bfdSessions: Array + }>(`/api/servers/${ospfServerId}/ospf`) + .then((data) => { + if (cancelled) return + const selected = liveServers.find((s) => String(s.id) === ospfServerId) + const visibleInterfaces = data.interfaces.filter((i) => !/^\(ref\s+\*.+\)$/.test(i.interface.trim())) + setOspfMeta({ + interfaces: visibleInterfaces.length, + areas: new Set(visibleInterfaces.map((i) => i.areaId)).size, + serverName: selected?.name || selected?.host || ospfServerId, + }) + }) + .catch(() => { + if (cancelled) return + setOspfMeta(null) + }) + return () => { cancelled = true } + }, [apiFetch, liveServers, ospfServerId, useLiveData]) + + useEffect(() => { + if (!useLiveData || !ospfServerId) return + let cancelled = false + void apiFetch<{ + changedCount: number + interfacesTotal: number + interfaces: Array<{ + interface: string + currentCost: number + optimalCost: number + score: number + pingMs: number + dlMbps: number + ulMbps: number + }> + changes: Array<{ + interface: string + currentCost: number + optimalCost: number + score: number + pingMs: number + dlMbps: number + ulMbps: number + }> + }>( + `/api/servers/${ospfServerId}/ospf/optimize/preview`, + { method: "POST", body: JSON.stringify({ pingWeight: settings.pingWeight }) }, + ) + .then((data) => { + if (cancelled) return + setOspfPreviewError("") + setOspfPreview(data) + }) + .catch((e) => { + if (cancelled) return + setOspfPreview(null) + setOspfPreviewError(e instanceof Error ? e.message : "Ошибка preview OSPF") + }) + return () => { cancelled = true } + }, [apiFetch, ospfServerId, settings.pingWeight, useLiveData]) + + const ospfPreviewLoading = useLiveData && Boolean(ospfServerId) && !ospfPreview && !ospfPreviewError + return (
+ {/* OSPF optimization from Route Optimizer */} + + +
+ + OSPF + + Route AI weight: ping {settings.pingWeight}% + +
+ + {!useLiveData && ( +

+ Доступно только в режиме живых данных. +

+ )} + + {useLiveData && ( + <> +
+
+ + + {ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"} + + +
+ +
+
Router
+
{ospfMeta?.serverName ?? "—"}
+
+ apply: {ospfApplyResult?.optimizedCount ?? 0} +
+
+
+ +
+
+ Preview изменений OSPF cost (до применения) + + {ospfPreviewLoading + ? "расчёт…" + : ospfPreview + ? `${ospfPreview.changedCount} из ${ospfPreview.interfacesTotal} изменятся` + : "нет данных"} + +
+
+ + + + {["Интерфейс", "Cost", "Score", "Ping", "Speed (dl/ul)"].map((h) => ( + + ))} + + + + {ospfPreviewError && ( + + + + )} + {!ospfPreviewLoading && !ospfPreviewError && (ospfPreview?.interfaces.length ?? 0) === 0 && ( + + + + )} + {(ospfPreview?.interfaces ?? []).map((row) => ( + + + + + + + + ))} + +
{h}
+ Ошибка preview: {ospfPreviewError} +
+ Интерфейсы OSPF не найдены для выбранного сервера. +
{row.interface} + {row.currentCost} + {" → "} + + {row.optimalCost} + + {row.score}{row.pingMs}ms↓{row.dlMbps} / ↑{row.ulMbps}
+
+
+ + {ospfApplyResult && ( +
+ Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}. +
+ )} + + {ospfApplyError && ( +
+ Ошибка OSPF-оптимизации: {ospfApplyError} +
+ )} + + )} +
+
+ {/* ─── ECMP / RPF / VRF section ──────────────────────────────── */}
diff --git a/app/(main)/settings/page.tsx b/app/(main)/settings/page.tsx index 9c4632a..759b923 100644 --- a/app/(main)/settings/page.tsx +++ b/app/(main)/settings/page.tsx @@ -61,7 +61,7 @@ const SECTION_GROUPS: { group: string; icon: React.ReactNode; items: string[] }[ { group: "Данные", icon: , items: ["Домены", "IP-диапазоны", "ASN", "Communities"] }, { group: "Управление", icon: , items: ["Серверы", "Фильтры", "Firewall", "GRE-туннели", "Бэкапы"] }, { group: "Инструменты", icon: , items: ["Оптимизатор маршрутов", "OSPF", "Диагностика GRE", "Терминал"] }, - { group: "Система", icon: , items: ["Настройки"] }, + { group: "Система", icon: , items: ["Оповещения", "Сбор данных", "Настройки"] }, ] const ALL_SECTIONS = SECTION_GROUPS.flatMap(g => g.items) @@ -147,21 +147,9 @@ const PERM_COLOR: Record = { write: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", } -const SECTIONS_NAV = ["Общие", "Сбор данных", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const +const SECTIONS_NAV = ["Общие", "EvoBGP", "Уведомления", "Пользователи", "API-ключи", "Безопасность"] as const type NavSection = typeof SECTIONS_NAV[number] -interface CollectorSettingsDto { - enabled: boolean - intervalSec: number - probeIntervalSec?: number - speedIntervalSec?: number - retentionDays: number - lastCollectedAt: string | null - lastDurationMs: number | null - lastError: string | null - collectorRunning?: boolean -} - function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {} @@ -864,17 +852,6 @@ export default function SettingsPage() { const [evoBusy, setEvoBusy] = useState<"test" | "refresh" | null>(null) const [showEvoKey, setShowEvoKey] = useState(false) - // collectors - const [trafficCollector, setTrafficCollector] = useState(null) - const [uptimeCollector, setUptimeCollector] = useState(null) - const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") - const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") - const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15") - const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60") - const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14") - const [collectorBusy, setCollectorBusy] = useState<"traffic" | "uptime" | null>(null) - const [collectorError, setCollectorError] = useState(null) - // general const [lang, setLang] = useState("ru") const [theme, setTheme] = useState("system") @@ -927,31 +904,6 @@ export default function SettingsPage() { // total sub-users count for summary const totalSubUsers = users.reduce((s, u) => s + u.subUsers.length, 0) - const loadCollectors = useCallback(async () => { - if (mode !== "live" || backendStatus !== true) return - setCollectorError(null) - try { - const [traffic, uptime] = await Promise.all([ - apiFetch("/api/traffic/settings"), - apiFetch("/api/uptime/settings"), - ]) - setTrafficCollector(traffic) - setUptimeCollector(uptime) - setTrafficIntervalDraft(String(traffic.intervalSec)) - setTrafficRetentionDraft(String(traffic.retentionDays)) - setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? uptime.intervalSec)) - setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60)) - setUptimeRetentionDraft(String(uptime.retentionDays)) - } catch (e) { - setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить настройки сборщиков") - } - }, [apiFetch, backendStatus, mode]) - - useEffect(() => { - if (section !== "Сбор данных") return - queueMicrotask(() => { void loadCollectors() }) - }, [section, loadCollectors]) - useEffect(() => { if (section !== "EvoBGP") return if (mode === "live" && backendStatus === true) queueMicrotask(() => { void evo.loadSettings() }) @@ -1178,145 +1130,6 @@ export default function SettingsPage() {
) - // ── Сбор данных ── - if (section === "Сбор данных") return ( -
- {(mode !== "live" || backendStatus !== true) && ( - - -

Раздел доступен только в live-режиме

-

- Переключи `Режим данных` в `Живые` и проверь доступность бекенда в разделе `Общие`. -

-
-
- )} - - {mode === "live" && backendStatus === true && ( - <> - {collectorError && ( - - -

{collectorError}

-
-
- )} - - - Сбор трафика - Настройки для `/traffic` - - -
- Состояние -
- - -
-
-
- setTrafficIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал (сек)" /> - setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" /> -
-
- - -
-
-

Последний сбор: {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}

-

Длительность: {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}

-

{trafficCollector?.lastError ? `Ошибка: ${trafficCollector.lastError}` : "Ошибок нет"}

-
-
-
- - - - Сбор uptime - Настройки для `/uptime` - - -
- Состояние -
- - -
-
-
- setUptimeIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал ping-проб (сек)" /> - setUptimeSpeedIntervalDraft(e.target.value)} className="h-8 text-sm" placeholder="Интервал speed-проб (сек)" /> -
-
- setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" placeholder="Хранение (дней)" /> -
-
- - -
-
-

Последний сбор: {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}

-

Длительность: {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}

-

{uptimeCollector?.lastError ? `Ошибка: ${uptimeCollector.lastError}` : "Ошибок нет"}

-
-
-
- - )} -
- ) - // ── EvoBGP ── if (section === "EvoBGP") return (
diff --git a/app/(main)/traffic/page.tsx b/app/(main)/traffic/page.tsx index a7c0f0a..96eb728 100644 --- a/app/(main)/traffic/page.tsx +++ b/app/(main)/traffic/page.tsx @@ -290,8 +290,17 @@ const userTraffic: UserTraffic[] = [ // ─── types ──────────────────────────────────────────────────────────────────── -const RANGES = ["5м", "15м", "1ч", "4ч", "24ч"] as const -type Range = typeof RANGES[number] +/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */ +const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const +type Range = (typeof TRAFFIC_RANGE_KEYS)[number] + +const TRAFFIC_RANGE_LABELS: Record = { + "5m": "5м", + "15m": "15м", + "1h": "1ч", + "4h": "4ч", + "24h": "24ч", +} type GroupMode = "servers" | "users" | "gre" type SortField = "rx" | "tx" | "name" | "sessions" @@ -575,10 +584,10 @@ function DetailHeader({ range, setRange, children }: {
{children}
- {RANGES.map(r => ( + {TRAFFIC_RANGE_KEYS.map((r) => ( ))}
@@ -772,7 +781,7 @@ export default function TrafficPage() { const [sortField, setSortField] = useState("rx") const [sortDir, setSortDir] = useState("desc") const [selectedId, setSelectedId] = useState("srv1") - const [range, setRange] = useState("1ч") + const [range, setRange] = useState("1h") const [search, setSearch] = useState("") const [liveServers, setLiveServers] = useState([]) const [liveBusy, setLiveBusy] = useState(false) diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index cdb2568..7c2d2c0 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input" import { Flag } from "@/components/flag" import { StatusDot } from "@/components/status-dot" import { Sparkline } from "@/components/sparkline" +import { PING_PROBE_WARN_RTT_MS } from "@/lib/ping-probe" import { cn } from "@/lib/utils" import { servers as mockServers, pingProbes as INIT_PROBES, filters, type Server, type Filter } from "@/lib/data" import type { PingProbe } from "@/lib/data" @@ -29,6 +30,11 @@ import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, } from "@/components/ui/sheet" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" /** Звёздочка «на дашборде» для mock — общий ключ с `dashboard/page.tsx` */ const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids" @@ -85,7 +91,7 @@ function jitter(base: number, pct: number) { function rttColor(rtt: number | null, loss: number): string { if (rtt === null || loss >= 100) return "text-[var(--status-offline-fg)]" - if (loss > 1 || rtt > 60) return "text-[var(--status-degraded-fg)]" + if (loss > 1 || rtt > PING_PROBE_WARN_RTT_MS) return "text-[var(--status-degraded-fg)]" return "text-[var(--status-online-fg)]" } @@ -93,6 +99,131 @@ function probeSparkColor(status: PingProbe["status"]): string { return status === "down" ? "var(--status-offline)" : status === "warn" ? "var(--status-degraded)" : "var(--status-online)" } +/** Развёрнутый график RTT под мини-спарклайном (та же серия `PingProbe.series`). */ +function ProbePingRttDetailChart({ + series, + status, + probeName, + target, +}: { + series: number[] + status: PingProbe["status"] + probeName: string + target: string +}) { + const stroke = probeSparkColor(status) + const data = series.map((v) => (v != null && Number.isFinite(v) ? Math.max(0, v) : 0)) + const valid = data.filter((v) => Number.isFinite(v)) + if (valid.length === 0) { + return ( +

+ Нет числовых точек RTT для графика (проба «{probeName}» → {target}). +

+ ) + } + const chartPts = valid.length >= 2 ? data : [valid[0] ?? 0, valid[0] ?? 0] + const W = 720 + const H = 168 + const pad = { l: 48, r: 14, t: 14, b: 36 } + const iw = W - pad.l - pad.r + const ih = H - pad.t - pad.b + const maxVal = Math.max(...chartPts, 1) + const minVal = Math.min(...chartPts) + const span = Math.max(1, maxVal - minVal) * 1.08 + const y0 = minVal - (span - (maxVal - minVal)) / 2 + const y1 = y0 + span + const xAt = (i: number) => pad.l + (chartPts.length <= 1 ? iw / 2 : (i / (chartPts.length - 1)) * iw) + const yAt = (v: number) => pad.t + (1 - (v - y0) / span) * ih + const lineD = chartPts + .map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`) + .join(" ") + const areaD = `${lineD} L ${xAt(chartPts.length - 1).toFixed(1)},${pad.t + ih} L ${pad.l},${pad.t + ih} Z` + const gridVals = [0, 0.25, 0.5, 0.75, 1] + const fmt = (v: number) => `${Math.round(v)} мс` + return ( +
+
+

+ {probeName} + {target} +

+

Ось X: старые замеры слева → новые справа · обзор ~1 ч

+
+ + {gridVals.map((g, i) => { + const y = pad.t + ih * (1 - g) + return ( + + + + {fmt(y0 + span * g)} + + + ) + })} + + + {[0, Math.floor((chartPts.length - 1) / 2), chartPts.length - 1] + .filter((i, idx, a) => a.indexOf(i) === idx) + .map((i) => ( + + {i === chartPts.length - 1 ? "сейчас" : i === 0 ? "раньше" : "·"} + + ))} + +
+ + min {Math.round(minVal)} мс + + + max {Math.round(maxVal)} мс + + {valid.length < 2 && ( + В ряду одна точка — линия для наглядности продублирована. + )} +
+
+ ) +} + +function probeGroupActionKey(srvId: string, group: { name: string; target: string }) { + return `${srvId}\t${group.name}\t${group.target}` +} + // ── shared components ────────────────────────────────────────────────────────── function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { @@ -313,6 +444,11 @@ function ServerPickerCards({ {server.host}
+ {!server.enabled && ( +

+ В инвентаре выключен — для ping всё равно можно выбрать, если бекенд достигает REST API. +

+ )} ) }) @@ -553,6 +689,8 @@ function LinkedFilterPickerCards({ interface ServerResource { serverId: string + /** false — в выбранном окне нет сэмплов ресурсов (не подменяем нулями «реальные» 0 %) */ + hasData?: boolean cpu: number cpuHistory: number[] ramUsed: number // MB @@ -577,6 +715,24 @@ interface BackendServer { os: string | null } +function mapBackendServersToServers(data: BackendServer[]): Server[] { + return data.map((s) => ({ + id: String(s.id), + name: s.name || s.host, + host: s.host, + model: "—", + os: s.os ?? "—", + site: s.site || "—", + country: s.country || "UN", + asn: "", + type: s.type, + enabled: s.enabled, + status: (s.status ?? "offline") as Server["status"], + latency: s.latency != null ? Math.round(s.latency) : null, + sessions: 0, + })) +} + interface SpeedTestRun { id: string startedAt: number @@ -620,6 +776,25 @@ interface SpeedProbeRow { lastPingError?: string | null } +/** Сервер есть в БД speed-проб, но удалён из каталога — показываем группу без ломания списка */ +function orphanSpeedSourceStub(id: string): Server { + return { + id, + name: `Нет в каталоге (#${id})`, + host: "—", + model: "—", + os: "—", + site: "—", + country: "UN", + asn: "", + type: "home-router", + enabled: false, + status: "offline", + latency: null, + sessions: 0, + } +} + function stripIpCidr(addr: string): string { const t = addr.trim() if (!t) return "" @@ -663,7 +838,7 @@ function findLinkedSpeedProbe( function fmtMB(mb: number): string { if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ` - return `${mb} МБ` + return `${mb.toFixed(1)} МБ` } function fmtUptime(sec: number): string { @@ -714,6 +889,7 @@ const INIT_RESOURCES: ServerResource[] = mockServers.map(s => { uptimeSeconds: (1 + h % 200) * 86400 + (h % 24) * 3600 + (h % 60) * 60, boardName: BOARD_MAP[s.type] ?? "RouterBOARD", temp: s.type !== "home-router" ? 34 + (h % 32) : undefined, + hasData: true, } }) @@ -747,30 +923,36 @@ function SortIcon({ k, sortKey, sortAsc }: { k: ResSortKey; sortKey: ResSortKey; type ResTypeFilter = "all" | "jump-host" | "exit-node" | "home-router" -function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; serversList: Server[] }) { +function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerResource[]; serversList: Server[]; liveApi?: boolean }) { const [sortKey, setSortKey] = useState("name") const [sortAsc, setSortAsc] = useState(true) const [resSearch, setResSearch] = useState("") const [typeFilter, setTypeFilter] = useState("all") - const rows = useMemo(() => resources.map(r => ({ - ...r, - server: serversList.find(s => s.id === r.serverId), - ramPct: Math.round(r.ramUsed / r.ramTotal * 100), - hddPct: Math.round(r.hddUsed / r.hddTotal * 100), - })).filter(r => r.server !== undefined), [resources, serversList]) + const rows = useMemo(() => resources.map((r) => { + const hasData = r.hasData !== false + const ramPct = hasData && r.ramTotal > 0 ? Math.round(r.ramUsed / r.ramTotal * 100) : 0 + const hddPct = hasData && r.hddTotal > 0 ? Math.round(r.hddUsed / r.hddTotal * 100) : 0 + return { + ...r, + hasData, + server: serversList.find(s => s.id === r.serverId), + ramPct, + hddPct, + } + }).filter(r => r.server !== undefined), [resources, serversList]) - // KPI aggregates - const online = rows.filter(r => r.server!.status === "online") - const avgCpu = online.length ? Math.round(online.reduce((s, r) => s + r.cpu, 0) / online.length) : 0 - const avgRam = online.length ? Math.round(online.reduce((s, r) => s + r.ramPct, 0) / online.length) : 0 - const highCpu = rows.filter(r => r.server!.status === "online" && r.cpu >= 85).length - const highRam = rows.filter(r => r.server!.status === "online" && r.ramPct >= 85).length - const highHdd = rows.filter(r => r.server!.status === "online" && r.hddPct >= 85).length + // KPI aggregates (только серверы с реальными сэмплами за окно) + const onlineWithSamples = rows.filter(r => r.server!.status === "online" && r.hasData) + const avgCpu = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.cpu, 0) / onlineWithSamples.length) : 0 + const avgRam = onlineWithSamples.length ? Math.round(onlineWithSamples.reduce((s, r) => s + r.ramPct, 0) / onlineWithSamples.length) : 0 + const highCpu = rows.filter(r => r.server!.status === "online" && r.hasData && r.cpu >= 85).length + const highRam = rows.filter(r => r.server!.status === "online" && r.hasData && r.ramPct >= 85).length + const highHdd = rows.filter(r => r.server!.status === "online" && r.hasData && r.hddPct >= 85).length // Alerts const alerts = useMemo(() => - rows.filter(r => r.server!.status === "online" && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)), + rows.filter(r => r.server!.status === "online" && r.hasData && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)), [rows], ) @@ -998,7 +1180,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; {visible.map(r => { const srv = r.server! const offline = srv.status !== "online" - const isCrit = !offline && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70) + const hasSamples = r.hasData !== false + const noMetrics = offline || !hasSamples + const isCrit = !noMetrics && (r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70) const cpuColor = r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)" return ( {srv.name} {srv.site} + {!offline && r.hasData === false && ( + + нет данных + + )}
{/* Board + ROS */}
- {r.boardName} + {hasSamples ? r.boardName : "—"} {srv.os}
{/* CPU */} - {offline - ? + {noMetrics + ? {offline ? "—" : "нет опроса"} : (
@@ -1046,8 +1235,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; {/* RAM */} - {offline - ? + {noMetrics + ? {offline ? "—" : "нет опроса"} : (
@@ -1063,8 +1252,8 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; {/* HDD */} - {offline - ? + {noMetrics + ? {offline ? "—" : "нет опроса"} : (
@@ -1081,13 +1270,13 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; {/* Uptime */} - {offline ? "—" : fmtUptime(r.uptimeSeconds)} + {noMetrics ? (offline ? "—" : "—") : fmtUptime(r.uptimeSeconds)} {/* Temp */} - {r.temp !== undefined && !offline ? ( + {r.temp !== undefined && !noMetrics ? ( = 70 ? "text-red-600 dark:text-red-400" : r.temp >= 55 ? "text-amber-600 dark:text-amber-400" @@ -1108,7 +1297,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[];

- Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим + {liveApi + ? "Автообновление каждые 5 сек (и кнопка «Обновить») · /system/resource via RouterOS REST API · backend" + : "Обновление каждые 5 сек · /system/resource via RouterOS REST API · demo-режим"}

) @@ -1118,8 +1309,9 @@ function ResourcesTab({ resources, serversList }: { resources: ServerResource[]; export default function UptimePage() { const [allServers, setAllServers] = useState(mockServers) - const { mode, backendUrl, backendStatus } = useDataSource() - const isLive = mode === "live" && backendStatus === true + const { mode, backendUrl, backendStatus, checkBackend } = useDataSource() + /** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */ + const liveApi = mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes") @@ -1132,6 +1324,11 @@ export default function UptimePage() { const [collapsed, setCollapsed] = useState>(new Set()) const [sheetOpen, setSheetOpen] = useState(false) const [opError, setOpError] = useState(null) + const [uptimeRefreshBusy, setUptimeRefreshBusy] = useState(false) + /** Ключ — probeGroupActionKey: ручной ping группы «сервер + назначение». */ + const [probeGroupPingBusy, setProbeGroupPingBusy] = useState>({}) + /** Раскрытый подробный график RTT по id пробы */ + const [probeRttChartOpen, setProbeRttChartOpen] = useState>({}) const [speedBusy, setSpeedBusy] = useState(false) const [speedError, setSpeedError] = useState(null) const [speedRuns, setSpeedRuns] = useState([]) @@ -1153,37 +1350,18 @@ export default function UptimePage() { }) useEffect(() => { - if (!isLive) { + if (!liveApi) { // eslint-disable-next-line react-hooks/set-state-in-effect setAllServers(mockServers) return } - apiFetch("/api/servers") - .then((data) => { - const mapped: Server[] = data.map((s) => ({ - id: String(s.id), - name: s.name || s.host, - host: s.host, - model: "—", - os: s.os ?? "—", - site: s.site || "—", - country: s.country || "UN", - asn: "", - type: s.type, - enabled: s.enabled, - status: (s.status ?? "offline") as Server["status"], - latency: s.latency != null ? Math.round(s.latency) : null, - sessions: 0, - })) - setAllServers(mapped) - }) - .catch(() => setAllServers([])) - }, [isLive, apiFetch]) + /** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */ + }, [liveApi]) useEffect(() => { - if (isLive) return + if (liveApi) return queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES))) - }, [isLive]) + }, [liveApi]) const isPausedRef = useRef(isPaused) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) @@ -1192,82 +1370,137 @@ export default function UptimePage() { const overviewReqRef = useRef(0) const loadLiveOverview = useCallback(async () => { - if (!isLive) return + if (!liveApi) return const myReq = ++overviewReqRef.current setOpError(null) try { - const data = await apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h") + const [serverRows, data] = await Promise.all([ + apiFetch("/api/servers"), + apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"), + ]) if (myReq !== overviewReqRef.current) return + setAllServers(mapBackendServersToServers(serverRows)) setProbes(data.probes) setResources(data.resources) } catch (e) { if (myReq !== overviewReqRef.current) return setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime") } - }, [apiFetch, isLive]) + }, [apiFetch, liveApi]) + + const reloadSpeedData = useCallback(async () => { + if (!liveApi) return + type RunRow = { + id: string + srcServerId: string + dstServerId: string + srcInterface: string + dstInterface: string + protocol: "tcp" | "udp" + direction: "transmit" | "receive" | "both" + durationSec: number + txAvgMbps: number + rxAvgMbps: number + status: "done" | "error" + error?: string | null + srcAddress?: string | null + dstAddress?: string | null + srcInterfaceAddress?: string | null + dstInterfaceAddress?: string | null + afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null + createdAt: string + } + try { + const [sp, runsRes] = await Promise.all([ + apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"), + apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"), + ]) + setSpeedProbes(sp.probes ?? []) + const rows = (runsRes.runs ?? []).map((r) => ({ + id: r.id, + startedAt: Date.parse(r.createdAt), + srcServerId: r.srcServerId, + dstServerId: r.dstServerId, + srcInterface: r.srcInterface || undefined, + dstInterface: r.dstInterface || undefined, + protocol: r.protocol, + direction: r.direction, + durationSec: r.durationSec, + txAvgMbps: Math.round(r.txAvgMbps ?? 0), + rxAvgMbps: Math.round(r.rxAvgMbps ?? 0), + status: (r.status === "error" ? "error" : "done") as "error" | "done", + command: "", + lines: r.error ? [`status: error`, r.error] : [], + afterBtPing: r.afterBtPing ?? null, + srcAddress: r.srcAddress ?? null, + dstAddress: r.dstAddress ?? null, + srcInterfaceAddress: r.srcInterfaceAddress ?? null, + dstInterfaceAddress: r.dstInterfaceAddress ?? null, + })) + setSpeedRuns(rows) + } catch { + setSpeedProbes([]) + setSpeedRuns([]) + } + }, [apiFetch, liveApi]) + + const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => { + if (!liveApi) return + if (opts?.showSpinner) setUptimeRefreshBusy(true) + let collectErr: string | null = null + try { + void checkBackend() + if (opts?.pollDevices) { + try { + await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" }) + } catch (e) { + collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)" + } + } + await Promise.all([loadLiveOverview(), reloadSpeedData()]) + if (collectErr) { + setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr)) + } + } finally { + if (opts?.showSpinner) setUptimeRefreshBusy(false) + } + }, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch]) + + const refreshProbeGroupPings = useCallback( + async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => { + if (!liveApi) return + const key = probeGroupActionKey(srvId, group) + setProbeGroupPingBusy((m) => ({ ...m, [key]: true })) + setOpError(null) + try { + await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", { + method: "POST", + body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }), + }) + await loadLiveOverview() + } catch (e) { + setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы") + } finally { + setProbeGroupPingBusy((m) => { + const next = { ...m } + delete next[key] + return next + }) + } + }, + [liveApi, apiFetch, loadLiveOverview], + ) useEffect(() => { - if (!isLive) return - queueMicrotask(() => { void loadLiveOverview() }) - }, [isLive, loadLiveOverview]) + if (!liveApi) return + queueMicrotask(() => { void refreshUptimeLive() }) + }, [liveApi, refreshUptimeLive]) useEffect(() => { - if (!isLive) return - void apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes") - .then((data) => setSpeedProbes(data.probes ?? [])) - .catch(() => setSpeedProbes([])) - }, [isLive, apiFetch]) - - useEffect(() => { - if (!isLive) return - void apiFetch<{ - runs: Array<{ - id: string - srcServerId: string - dstServerId: string - srcInterface: string - dstInterface: string - protocol: "tcp" | "udp" - direction: "transmit" | "receive" | "both" - durationSec: number - txAvgMbps: number - rxAvgMbps: number - status: "done" | "error" - error?: string | null - srcAddress?: string | null - dstAddress?: string | null - srcInterfaceAddress?: string | null - dstInterfaceAddress?: string | null - afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null - createdAt: string - }> - }>("/api/uptime/speed-test/runs") - .then((data) => { - const rows = (data.runs ?? []).map((r) => ({ - id: r.id, - startedAt: Date.parse(r.createdAt), - srcServerId: r.srcServerId, - dstServerId: r.dstServerId, - srcInterface: r.srcInterface || undefined, - dstInterface: r.dstInterface || undefined, - protocol: r.protocol, - direction: r.direction, - durationSec: r.durationSec, - txAvgMbps: Math.round(r.txAvgMbps ?? 0), - rxAvgMbps: Math.round(r.rxAvgMbps ?? 0), - status: (r.status === "error" ? "error" : "done") as "error" | "done", - command: "", - lines: r.error ? [`status: error`, r.error] : [], - afterBtPing: r.afterBtPing ?? null, - srcAddress: r.srcAddress ?? null, - dstAddress: r.dstAddress ?? null, - srcInterfaceAddress: r.srcInterfaceAddress ?? null, - dstInterfaceAddress: r.dstInterfaceAddress ?? null, - })) - setSpeedRuns(rows) - }) - .catch(() => setSpeedRuns([])) - }, [isLive, apiFetch]) + if (!liveApi || isPaused) return + const id = setInterval(() => { void refreshUptimeLive() }, 5_000) + return () => clearInterval(id) + }, [liveApi, isPaused, refreshUptimeLive]) // add-probe form const [newSrcId, setNewSrcId] = useState("") @@ -1279,13 +1512,11 @@ export default function UptimePage() { const [srcInterfaces, setSrcInterfaces] = useState>([]) const [srcInterfacesBusy, setSrcInterfacesBusy] = useState(false) - const selectableSources = useMemo( - () => allServers.filter(s => s.enabled), - [allServers], - ) + /** Источник для ping/speed: весь каталог (в т.ч. выключенные в inventory), иначе Home Router нельзя выбрать */ + const selectableSources = useMemo(() => allServers, [allServers]) const loadSpeedInterfaces = useCallback(async (serverId: string) => { - if (!isLive) return + if (!liveApi) return if (!serverId || speedIfaces[serverId]) return const id = Number.parseInt(serverId, 10) if (!Number.isFinite(id)) return @@ -1295,7 +1526,7 @@ export default function UptimePage() { } catch { setSpeedIfaces((prev) => ({ ...prev, [serverId]: [] })) } - }, [apiFetch, isLive, speedIfaces]) + }, [apiFetch, liveApi, speedIfaces]) /** После загрузки списков интерфейсов сбросить выбор, если интерфейс не активен или отсутствует в списке */ useEffect(() => { @@ -1333,7 +1564,7 @@ export default function UptimePage() { setNewSrcInterface("") return } - if (!isLive) { + if (!liveApi) { setSrcInterfaces([]) setNewSrcInterface("") return @@ -1356,22 +1587,23 @@ export default function UptimePage() { setNewSrcInterface("") }) .finally(() => setSrcInterfacesBusy(false)) - }, [sheetOpen, newSrcId, isLive, apiFetch]) + }, [sheetOpen, newSrcId, liveApi, apiFetch]) // servers that have at least one probe (preserve data-order) const probedServerIds = useMemo( () => [...new Set(probes.map(p => p.srcServerId))], [probes], ) - const probedServers = useMemo( - () => allServers.filter(s => probedServerIds.includes(s.id)), - [probedServerIds, allServers], - ) + const probedServers = useMemo(() => { + const inCatalog = allServers.filter((s) => probedServerIds.includes(s.id)) + const orphanIds = probedServerIds.filter((id) => !inCatalog.some((s) => s.id === id)) + return [...inCatalog, ...orphanIds.map(orphanSpeedSourceStub)] + }, [probedServerIds, allServers]) // live RTT tick useEffect(() => { const id = setInterval(() => { - if (isLive) return + if (liveApi) return if (isPausedRef.current) return setProbes(prev => prev.map(p => { if (!p.enabled || p.status === "down" || p.rtt === null) return p @@ -1381,16 +1613,17 @@ export default function UptimePage() { })) }, 3000) return () => clearInterval(id) - }, [isLive]) + }, [liveApi]) // live resource tick useEffect(() => { const id = setInterval(() => { - if (isLive) return + if (liveApi) return if (isPausedRef.current) return setResources(prev => prev.map(r => { const srv = allServers.find(s => s.id === r.serverId) if (!srv || srv.status !== "online") return r + if (r.hasData === false) return r const newCpu = Math.min(99, Math.max(1, r.cpu + Math.round((Math.random() - 0.48) * 8))) const newRam = Math.min(r.ramTotal - 64, Math.max(256, r.ramUsed + Math.round((Math.random() - 0.5) * 128))) const newTemp = r.temp !== undefined @@ -1407,7 +1640,7 @@ export default function UptimePage() { })) }, 5000) return () => clearInterval(id) - }, [isLive, allServers]) + }, [liveApi, allServers]) // ── derived ── const stats = useMemo(() => ({ @@ -1420,7 +1653,7 @@ export default function UptimePage() { const alertCount = useMemo(() => resources.filter(r => { const s = allServers.find(x => x.id === r.serverId) - if (!s || s.status !== "online") return false + if (!s || s.status !== "online" || r.hasData === false) return false const ramPct = Math.round(r.ramUsed / r.ramTotal * 100) const hddPct = Math.round(r.hddUsed / r.hddTotal * 100) return r.cpu >= 85 || ramPct >= 85 || hddPct >= 85 || (r.temp ?? 0) >= 70 @@ -1467,16 +1700,19 @@ export default function UptimePage() { arr.push(p) map.set(p.srcServerId, arr) } - return selectableSources - .filter((s) => map.has(s.id)) - .map((s) => ({ - server: s, - probes: (map.get(s.id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)), - })) - }, [speedProbes, selectableSources]) + const ids = [...map.keys()].sort((a, b) => { + const sa = allServers.find((s) => s.id === a) ?? orphanSpeedSourceStub(a) + const sb = allServers.find((s) => s.id === b) ?? orphanSpeedSourceStub(b) + return (sa.host + sa.name).localeCompare(sb.host + sb.name) + }) + return ids.map((id) => ({ + server: allServers.find((s) => s.id === id) ?? orphanSpeedSourceStub(id), + probes: (map.get(id) ?? []).sort((a, b) => (a.dstServerId + a.id).localeCompare(b.dstServerId + b.id)), + })) + }, [speedProbes, allServers]) const persistProbes = useCallback((rows: PingProbe[]) => { - if (!isLive) return + if (!liveApi) return void apiFetch("/api/uptime/probes", { method: "PUT", body: JSON.stringify({ @@ -1492,17 +1728,17 @@ export default function UptimePage() { })), }), }).catch(() => {}) - }, [apiFetch, isLive]) + }, [apiFetch, liveApi]) const toggleDashboardStar = useCallback((id: string) => { const cur = probes.find((p) => p.id === id) if (!cur) return const nextVal = !cur.showOnDashboard - if (isLive) { + if (liveApi) { overviewReqRef.current += 1 } setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p))) - if (isLive) { + if (liveApi) { void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -1526,10 +1762,10 @@ export default function UptimePage() { window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) } } - }, [probes, isLive, apiFetch, loadLiveOverview]) + }, [probes, liveApi, apiFetch, loadLiveOverview]) const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => { - if (!isLive) return + if (!liveApi) return void apiFetch("/api/uptime/speed-probes", { method: "PUT", body: JSON.stringify({ @@ -1551,7 +1787,7 @@ export default function UptimePage() { })), }), }).catch(() => {}) - }, [apiFetch, isLive]) + }, [apiFetch, liveApi]) // ── actions ── const toggleProbe = (id: string, v: boolean) => @@ -1562,7 +1798,7 @@ export default function UptimePage() { }) const deleteProbe = (id: string) => { - if (!isLive) { + if (!liveApi) { const s = readMockDashboardStarIds() s.delete(id) writeMockDashboardStarIds(s) @@ -1747,7 +1983,7 @@ export default function UptimePage() { lines: ["status: running..."], }, ...prev].slice(0, 20)) try { - if (isLive) { + if (liveApi) { const payload = { runId, probeId: probe.id, @@ -1974,15 +2210,15 @@ export default function UptimePage() { : <>Пауза} - {tab === "probes" && ( @@ -2004,9 +2240,11 @@ export default function UptimePage() {
- {isLive - ? "Live (backend)" - : "Live · проба обновляется каждые 3с · ресурсы каждые 5с"} + {liveApi + ? (backendStatus === false + ? "Live: /health не ответил — проверьте URL в настройках; запросы к API выполняются" + : "Live (backend)") + : "Демо-режим · пробы/ресурсы локальные; «Обновить» сбрасывает макет"}
)} {isPaused && ( @@ -2066,7 +2304,7 @@ export default function UptimePage() {
{/* ── resources tab ── */} - {tab === "resources" && } + {tab === "resources" && } {/* ── speed tab ── */} {tab === "speed" && ( @@ -2552,13 +2790,30 @@ export default function UptimePage() { {!isCollapsed && ( <>
- {probeGroups.map((group) => ( + {probeGroups.map((group) => { + const groupBusyKey = probeGroupActionKey(srv.id, group) + const groupBusy = !!probeGroupPingBusy[groupBusyKey] + return (
- - {group.name} - {group.target} - {group.probes.length} интерф. + + {group.name} + {group.target} +
+ {group.probes.length} интерф. + +
{ const linkedSp = findLinkedSpeedProbe(p, speedProbes, allServers, speedIfaces) return ( -
+
+ setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))} + > + + + + Подробный график RTT + + ({p.series.length} точ.) + + + + +
+ +
+
+
+
) })} @@ -2691,7 +2982,8 @@ export default function UptimePage() { Добавить интерфейс для {group.name} ({group.target})
- ))} + ) + })}
)} @@ -2894,9 +3186,12 @@ export default function UptimePage() {
- + s.enabled)} + options={selectableSources} selectedId={newSrcId} onSelect={(id) => setNewSrcId(id)} /> diff --git a/backend/package.json b/backend/package.json index bd4339c..45afd4b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test:alert-engine": "tsx src/services/alert-engine/groups.test.ts && tsx src/services/alert-engine/evaluate-rule.test.ts && tsx src/services/alert-engine/confirm-stability.test.ts", "start": "node dist/index.js", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", diff --git a/backend/src/constants/ping-probe.ts b/backend/src/constants/ping-probe.ts new file mode 100644 index 0000000..cc4330f --- /dev/null +++ b/backend/src/constants/ping-probe.ts @@ -0,0 +1,6 @@ +/** + * Порог среднего RTT (мс) для ICMP-проб: выше — статус `warn` при потерях ≤1%. + * 60 мс было слишком жёстко для WAN/CDN (например ~65–80 мс до 1.1.1.1 из РФ — нормальная география). + * Фронт: держите то же значение в `lib/ping-probe.ts`. + */ +export const PING_PROBE_WARN_RTT_MS = 120 diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index ee511aa..a61101a 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1,4 +1,6 @@ import Database from "better-sqlite3" + +type SqliteHandle = InstanceType import { drizzle } from "drizzle-orm/better-sqlite3" import { env } from "../config.js" import * as schema from "./schema.js" @@ -190,6 +192,60 @@ CREATE TABLE IF NOT EXISTS uptime_speed_test_runs ( CREATE INDEX IF NOT EXISTS idx_uptime_speed_test_runs_created_at ON uptime_speed_test_runs(created_at); +CREATE TABLE IF NOT EXISTS scheduler_runs ( + id TEXT PRIMARY KEY, + job_key TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT, + duration_ms INTEGER NOT NULL DEFAULT 0, + result_json TEXT +); +CREATE INDEX IF NOT EXISTS idx_scheduler_runs_job_time + ON scheduler_runs(job_key, finished_at); + +CREATE TABLE IF NOT EXISTS servers_api_ping_settings ( + id INTEGER PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + interval_sec INTEGER NOT NULL DEFAULT 120, + last_collected_at TEXT, + last_duration_ms INTEGER, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS servers_rest_ping_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + sampled_at TEXT NOT NULL, + ok INTEGER NOT NULL DEFAULT 0, + latency_ms INTEGER, + error TEXT, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_servers_rest_ping_samples_server_id + ON servers_rest_ping_samples(server_id, id DESC); + +CREATE TABLE IF NOT EXISTS alert_gre_tunnel_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sampled_at TEXT NOT NULL, + target_label TEXT NOT NULL, + status TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_alert_gre_tunnel_samples_label_id + ON alert_gre_tunnel_samples(target_label, id DESC); + +CREATE TABLE IF NOT EXISTS alert_bgp_peer_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sampled_at TEXT NOT NULL, + peer_key TEXT NOT NULL, + state TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_alert_bgp_peer_samples_key_id + ON alert_bgp_peer_samples(peer_key, id DESC); + CREATE TABLE IF NOT EXISTS evobgp_settings ( id INTEGER PRIMARY KEY, base_url TEXT NOT NULL DEFAULT '', @@ -197,6 +253,120 @@ CREATE TABLE IF NOT EXISTS evobgp_settings ( enabled INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS alert_telegram_settings ( + id INTEGER PRIMARY KEY, + bot_token TEXT NOT NULL DEFAULT '', + chat_id TEXT NOT NULL DEFAULT '', + message_thread_id INTEGER, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + target TEXT NOT NULL, + condition TEXT NOT NULL, + severity TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + cooldown TEXT NOT NULL DEFAULT '5м', + rule_chat_id TEXT NOT NULL DEFAULT '', + recovery_mode TEXT NOT NULL DEFAULT 'always', + recovery_stability_sec INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS alert_history ( + id TEXT PRIMARY KEY, + rule_id TEXT, + rule_name TEXT NOT NULL, + severity TEXT NOT NULL, + message TEXT NOT NULL, + sent_ok INTEGER NOT NULL DEFAULT 1, + fired_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_alert_history_fired_at ON alert_history(fired_at); +CREATE INDEX IF NOT EXISTS idx_alert_history_rule_id ON alert_history(rule_id); + +CREATE TABLE IF NOT EXISTS alert_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + combine_mode TEXT NOT NULL DEFAULT 'any', + enabled INTEGER NOT NULL DEFAULT 1, + cooldown_override TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS alert_rule_targets ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL, + target TEXT NOT NULL, + sort_index INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_alert_rule_targets_rule ON alert_rule_targets(rule_id); + +CREATE TABLE IF NOT EXISTS alert_rule_conditions ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL, + condition_line TEXT NOT NULL, + sort_index INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_alert_rule_conditions_rule ON alert_rule_conditions(rule_id); + +CREATE TABLE IF NOT EXISTS alert_engine_state ( + scope_key TEXT PRIMARY KEY, + last_fired_at TEXT NOT NULL DEFAULT '', + last_payload_hash TEXT +); + +CREATE TABLE IF NOT EXISTS alert_engine_prev_live ( + kind TEXT PRIMARY KEY, + payload_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS alert_engine_confirm_pending ( + rule_id TEXT PRIMARY KEY, + payload_hash TEXT NOT NULL, + since_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS alert_destinations ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'telegram', + label TEXT NOT NULL DEFAULT '', + telegram_chat_id TEXT NOT NULL DEFAULT '', + message_thread_id INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS alert_outbox ( + id TEXT PRIMARY KEY, + dedupe_key TEXT NOT NULL, + channel TEXT NOT NULL DEFAULT 'telegram', + status TEXT NOT NULL DEFAULT 'pending', + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + next_attempt_at TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + sent_at TEXT, + last_error TEXT +); +CREATE INDEX IF NOT EXISTS idx_alert_outbox_status_next_attempt + ON alert_outbox(status, next_attempt_at); +CREATE INDEX IF NOT EXISTS idx_alert_outbox_dedupe + ON alert_outbox(dedupe_key); + +CREATE TABLE IF NOT EXISTS alert_engine_cursor ( + id INTEGER PRIMARY KEY, + last_source_finished_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); `) // Lightweight schema evolution for existing databases without migrations @@ -215,6 +385,10 @@ const hasShowOnDashboardColumn = uptimeProbeCols.some((c) => c.name === "show_on if (!hasShowOnDashboardColumn) { sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN show_on_dashboard INTEGER NOT NULL DEFAULT 0`) } +const hasProbeIntervalSecColumn = uptimeProbeCols.some((c) => c.name === "interval_sec") +if (!hasProbeIntervalSecColumn) { + sqlite.exec(`ALTER TABLE uptime_probes ADD COLUMN interval_sec INTEGER NOT NULL DEFAULT 0`) +} const uptimeSettingsCols = sqlite.prepare(`PRAGMA table_info('uptime_settings')`).all() as Array<{ name?: string }> const hasProbeIntervalColumn = uptimeSettingsCols.some((c) => c.name === "probe_interval_sec") @@ -226,6 +400,16 @@ if (!hasSpeedIntervalColumn) { sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_interval_sec INTEGER NOT NULL DEFAULT 60`) } +const hasUptimeJobFlags = uptimeSettingsCols.some((c) => c.name === "resources_enabled") +if (!hasUptimeJobFlags) { + sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN resources_enabled INTEGER NOT NULL DEFAULT 1`) + sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN ping_enabled INTEGER NOT NULL DEFAULT 1`) + sqlite.exec(`ALTER TABLE uptime_settings ADD COLUMN speed_enabled INTEGER NOT NULL DEFAULT 1`) + sqlite.exec( + `UPDATE uptime_settings SET resources_enabled = enabled, ping_enabled = enabled, speed_enabled = enabled WHERE id = 1`, + ) +} + const uptimeSpeedProbeCols = sqlite.prepare(`PRAGMA table_info('uptime_speed_probes')`).all() as Array<{ name?: string }> const ensureSpeedProbeCol = (name: string, ddl: string) => { if (!uptimeSpeedProbeCols.some((c) => c.name === name)) sqlite.exec(ddl) @@ -240,6 +424,11 @@ ensureSpeedProbeCol("last_ping_loss_pct", `ALTER TABLE uptime_speed_probes ADD C ensureSpeedProbeCol("last_ping_at", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_at TEXT`) ensureSpeedProbeCol("last_ping_error", `ALTER TABLE uptime_speed_probes ADD COLUMN last_ping_error TEXT`) +const schedulerRunCols = sqlite.prepare(`PRAGMA table_info('scheduler_runs')`).all() as Array<{ name?: string }> +if (!schedulerRunCols.some((c) => c.name === "result_json")) { + sqlite.exec(`ALTER TABLE scheduler_runs ADD COLUMN result_json TEXT`) +} + const serverCols = sqlite.prepare(`PRAGMA table_info('servers')`).all() as Array<{ name?: string }> if (!serverCols.some((c) => c.name === "lan_subnet")) { sqlite.exec(`ALTER TABLE servers ADD COLUMN lan_subnet TEXT NOT NULL DEFAULT ''`) @@ -248,6 +437,44 @@ if (!serverCols.some((c) => c.name === "wan_uplinks")) { sqlite.exec(`ALTER TABLE servers ADD COLUMN wan_uplinks TEXT NOT NULL DEFAULT '[]'`) } +const alertTgCols = sqlite.prepare(`PRAGMA table_info('alert_telegram_settings')`).all() as Array<{ name?: string }> +if (!alertTgCols.some((c) => c.name === "message_thread_id")) { + sqlite.exec(`ALTER TABLE alert_telegram_settings ADD COLUMN message_thread_id INTEGER`) +} + +const alertRulesCols = sqlite.prepare(`PRAGMA table_info('alert_rules')`).all() as Array<{ name?: string }> +if (!alertRulesCols.some((c) => c.name === "group_id")) { + sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN group_id TEXT`) +} +if (!alertRulesCols.some((c) => c.name === "confirm_stability_sec")) { + sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN confirm_stability_sec INTEGER`) +} +if (!alertRulesCols.some((c) => c.name === "recovery_mode")) { + sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_mode TEXT NOT NULL DEFAULT 'always'`) +} +if (!alertRulesCols.some((c) => c.name === "recovery_stability_sec")) { + sqlite.exec(`ALTER TABLE alert_rules ADD COLUMN recovery_stability_sec INTEGER`) +} + +const alertHistoryCols = sqlite.prepare(`PRAGMA table_info('alert_history')`).all() as Array<{ name?: string }> +if (!alertHistoryCols.some((c) => c.name === "group_id")) { + sqlite.exec(`ALTER TABLE alert_history ADD COLUMN group_id TEXT`) +} + +/** Одна строка target на правило из legacy-колонки `alert_rules.target` */ +sqlite.exec(` +INSERT OR IGNORE INTO alert_rule_targets (id, rule_id, target, sort_index) +SELECT 'rt-' || id || '-0', id, target, 0 FROM alert_rules +WHERE id NOT IN (SELECT rule_id FROM alert_rule_targets) +`) + +/** Одна строка условия из legacy `alert_rules.condition` */ +sqlite.exec(` +INSERT OR IGNORE INTO alert_rule_conditions (id, rule_id, condition_line, sort_index) +SELECT 'rc-' || id || '-0', id, condition, 0 FROM alert_rules +WHERE id NOT IN (SELECT rule_id FROM alert_rule_conditions) +`) + sqlite.exec(` INSERT INTO traffic_settings (id, enabled, interval_sec, retention_days) SELECT 1, 1, 30, 14 @@ -266,4 +493,25 @@ SELECT 1, '', '', 0 WHERE NOT EXISTS (SELECT 1 FROM evobgp_settings WHERE id = 1); `) +sqlite.exec(` +INSERT INTO servers_api_ping_settings (id, enabled, interval_sec) +SELECT 1, 0, 120 +WHERE NOT EXISTS (SELECT 1 FROM servers_api_ping_settings WHERE id = 1); +`) + +sqlite.exec(` +INSERT INTO alert_telegram_settings (id, bot_token, chat_id) +SELECT 1, '', '' +WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1); +`) + +sqlite.exec(` +INSERT INTO alert_engine_cursor (id, last_source_finished_at) +SELECT 1, NULL +WHERE NOT EXISTS (SELECT 1 FROM alert_engine_cursor WHERE id = 1); +`) + export const db = drizzle(sqlite, { schema }) + +/** Прямой доступ к better-sqlite3 для сложных read-only запросов (напр. /api/alerts). */ +export const sqliteDatabase: SqliteHandle = sqlite diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 32ad307..4a02861 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -115,6 +115,46 @@ export const trafficSettings = sqliteTable("traffic_settings", { updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), }) +/** Настройки фоновой проверки доступности RouterOS REST API по серверам каталога. */ +export const serversApiPingSettings = sqliteTable("servers_api_ping_settings", { + id: integer("id").primaryKey(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(false), + intervalSec: integer("interval_sec").notNull().default(120), + lastCollectedAt: text("last_collected_at"), + lastDurationMs: integer("last_duration_ms"), + lastError: text("last_error"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Сырые пробы REST `/system/identity` по серверам — для UI сбора и для `buildSignalSnapshot` (алерты). */ +export const serversRestPingSamples = sqliteTable("servers_rest_ping_samples", { + id: integer("id").primaryKey({ autoIncrement: true }), + serverId: integer("server_id") + .notNull() + .references(() => servers.id, { onDelete: "cascade" }), + sampledAt: text("sampled_at").notNull(), + ok: integer("ok", { mode: "boolean" }).notNull(), + latencyMs: integer("latency_ms"), + error: text("error"), +}) + +/** Сырые снимки GRE для алертов — пишет джоба `gre_bgp`. */ +export const alertGreTunnelSamples = sqliteTable("alert_gre_tunnel_samples", { + id: integer("id").primaryKey({ autoIncrement: true }), + sampledAt: text("sampled_at").notNull(), + targetLabel: text("target_label").notNull(), + status: text("status").notNull(), +}) + +/** Сырые снимки BGP-сессий для алертов — пишет джоба `gre_bgp`. */ +export const alertBgpPeerSamples = sqliteTable("alert_bgp_peer_samples", { + id: integer("id").primaryKey({ autoIncrement: true }), + sampledAt: text("sampled_at").notNull(), + peerKey: text("peer_key").notNull(), + state: text("state").notNull(), +}) + // ── raw traffic samples (per server/interface/timepoint) ────────────────────── export const trafficSamples = sqliteTable("traffic_samples", { @@ -136,7 +176,11 @@ export const trafficSamples = sqliteTable("traffic_samples", { export const uptimeSettings = sqliteTable("uptime_settings", { id: integer("id").primaryKey(), + /** Устаревший агрегат: синхронизируется как resources ∨ ping ∨ speed (для совместимости). */ enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + resourcesEnabled: integer("resources_enabled", { mode: "boolean" }).notNull().default(true), + pingEnabled: integer("ping_enabled", { mode: "boolean" }).notNull().default(true), + speedEnabled: integer("speed_enabled", { mode: "boolean" }).notNull().default(true), intervalSec: integer("interval_sec").notNull().default(15), probeIntervalSec: integer("probe_interval_sec").notNull().default(15), speedIntervalSec: integer("speed_interval_sec").notNull().default(60), @@ -158,6 +202,8 @@ export const uptimeProbes = sqliteTable("uptime_probes", { target: text("target").notNull(), probeFilter: text("probe_filter").notNull().default("—"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + /** 0 — брать глобальный probe_interval_sec из uptime_settings */ + intervalSec: integer("interval_sec").notNull().default(0), /** Выводить пробу в блоке «Активные пробы» на дашборде */ showOnDashboard: integer("show_on_dashboard", { mode: "boolean" }).notNull().default(false), sortOrder: integer("sort_order").notNull().default(0), @@ -231,6 +277,155 @@ export const evobgpSettings = sqliteTable("evobgp_settings", { updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), }) +/** Единственная строка id=1: токен и чат Telegram для оповещений. */ +export const alertTelegramSettings = sqliteTable("alert_telegram_settings", { + id: integer("id").primaryKey(), + botToken: text("bot_token").notNull().default(""), + chatId: text("chat_id").notNull().default(""), + /** Тема супергруппы (forum): `message_thread_id` в Bot API; NULL — общий чат */ + messageThreadId: integer("message_thread_id"), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */ +export const alertGroups = sqliteTable("alert_groups", { + id: text("id").primaryKey(), + name: text("name").notNull(), + combineMode: text("combine_mode", { enum: ["any", "all"] }).notNull().default("any"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + /** Переопределение cooldown для агрегата группы; NULL — по умолчанию 5м */ + cooldownOverride: text("cooldown_override", { + enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"], + }), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Правила оповещений (UI /alerts). */ +export const alertRules = sqliteTable("alert_rules", { + id: text("id").primaryKey(), + name: text("name").notNull(), + type: text("type", { + enum: ["gre-tunnel", "bgp-peer", "bgp-prefix", "gre-client", "server", "rtt", "loss", "traffic"], + }).notNull(), + /** Сводная подпись (первый target или join); для совместимости и поиска */ + target: text("target").notNull(), + condition: text("condition").notNull(), + severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + cooldown: text("cooldown", { + enum: ["1м", "5м", "15м", "1ч", "4ч", "24ч"], + }).notNull().default("5м"), + chatId: text("rule_chat_id").notNull().default(""), + /** NULL / 0 — выкл. Секунды: уведомление только если условие держится столько времени без «отмены» (восстановление сигнала). */ + confirmStabilitySec: integer("confirm_stability_sec"), + /** Политика recovery-уведомления для правила. */ + recoveryMode: text("recovery_mode", { + enum: ["always", "never", "conditional"], + }).notNull().default("always"), + /** Доп. задержка подтверждения для recovery при `conditional`; NULL — без доп. ожидания. */ + recoveryStabilitySec: integer("recovery_stability_sec"), + /** NULL — правило вне группы (отдельные отправки по cooldown правила) */ + groupId: text("group_id"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Несколько объектов на правило; срабатывание по OR (хотя бы один). */ +export const alertRuleTargets = sqliteTable("alert_rule_targets", { + id: text("id").primaryKey(), + ruleId: text("rule_id").notNull(), + target: text("target").notNull(), + sortIndex: integer("sort_index").notNull().default(0), +}) + +/** Несколько условий на правило; срабатывание по OR (любое из выбранных). */ +export const alertRuleConditions = sqliteTable("alert_rule_conditions", { + id: text("id").primaryKey(), + ruleId: text("rule_id").notNull(), + conditionLine: text("condition_line").notNull(), + sortIndex: integer("sort_index").notNull().default(0), +}) + +/** Состояние движка: cooldown / дедуп по ключу rule:id или group:id */ +export const alertEngineState = sqliteTable("alert_engine_state", { + scopeKey: text("scope_key").primaryKey(), + lastFiredAt: text("last_fired_at").notNull().default(""), + lastPayloadHash: text("last_payload_hash"), +}) + +/** Снимок прошлого тика для live-сигналов (GRE/BGP): переходы «offline» / «восстановился». */ +export const alertEnginePrevLive = sqliteTable("alert_engine_prev_live", { + kind: text("kind", { enum: ["gre", "bgp"] }).primaryKey(), + payloadJson: text("payload_json").notNull().default("{}"), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Ожидание стабильности срабатывания по правилу (антидребезг). */ +export const alertEngineConfirmPending = sqliteTable("alert_engine_confirm_pending", { + ruleId: text("rule_id").primaryKey(), + payloadHash: text("payload_hash").notNull(), + sinceAt: text("since_at").notNull(), +}) + +/** Задел: несколько получателей Telegram (пока UI не подключён) */ +export const alertDestinations = sqliteTable("alert_destinations", { + id: text("id").primaryKey(), + kind: text("kind", { enum: ["telegram"] }).notNull().default("telegram"), + label: text("label").notNull().default(""), + telegramChatId: text("telegram_chat_id").notNull().default(""), + messageThreadId: integer("message_thread_id"), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Журнал отправок / срабатываний (персист + при необходимости пополняется воркером). */ +export const alertHistory = sqliteTable("alert_history", { + id: text("id").primaryKey(), + ruleId: text("rule_id"), + groupId: text("group_id"), + ruleName: text("rule_name").notNull(), + severity: text("severity", { enum: ["critical", "warning", "info"] }).notNull(), + message: text("message").notNull(), + sentOk: integer("sent_ok", { mode: "boolean" }).notNull().default(true), + firedAt: text("fired_at").notNull(), +}) + +/** Outbox доставки уведомлений: ретраи и идемпотентность отправки каналов. */ +export const alertOutbox = sqliteTable("alert_outbox", { + id: text("id").primaryKey(), + dedupeKey: text("dedupe_key").notNull(), + channel: text("channel", { enum: ["telegram"] }).notNull().default("telegram"), + status: text("status", { enum: ["pending", "sent", "failed"] }).notNull().default("pending"), + retryCount: integer("retry_count").notNull().default(0), + maxRetries: integer("max_retries").notNull().default(3), + nextAttemptAt:text("next_attempt_at").notNull(), + payloadJson: text("payload_json").notNull(), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + sentAt: text("sent_at"), + lastError: text("last_error"), +}) + +/** Cursor движка: watermark последней обработанной точки snapshot-источников. */ +export const alertEngineCursor = sqliteTable("alert_engine_cursor", { + id: integer("id").primaryKey(), + lastSourceFinishedAt: text("last_source_finished_at"), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}) + +/** Журнал прогонов планировщика (аудит). */ +export const schedulerRuns = sqliteTable("scheduler_runs", { + id: text("id").primaryKey(), + jobKey: text("job_key").notNull(), + startedAt: text("started_at").notNull(), + finishedAt: text("finished_at").notNull(), + status: text("status", { enum: ["ok", "error"] }).notNull(), + error: text("error"), + durationMs: integer("duration_ms").notNull().default(0), + /** JSON: см. `SchedulerRunSnapshot` в types/scheduler-run-snapshot.ts */ + resultJson: text("result_json"), +}) + export const uptimeSpeedTestRuns = sqliteTable("uptime_speed_test_runs", { id: text("id").primaryKey(), probeId: text("probe_id"), @@ -264,6 +459,7 @@ export type SnapshotInsert = typeof serverSnapshots.$inferInsert export type FilterRuleRow = typeof filterRules.$inferSelect export type RecursiveRouteRow = typeof recursiveRoutes.$inferSelect export type TrafficSettingsRow = typeof trafficSettings.$inferSelect +export type ServersApiPingSettingsRow = typeof serversApiPingSettings.$inferSelect export type TrafficSampleRow = typeof trafficSamples.$inferSelect export type UptimeSettingsRow = typeof uptimeSettings.$inferSelect export type UptimeProbeRow = typeof uptimeProbes.$inferSelect @@ -271,4 +467,14 @@ export type UptimeProbeSampleRow = typeof uptimeProbeSamples.$inferSelect export type UptimeResourceSampleRow = typeof uptimeResourceSamples.$inferSelect export type UptimeSpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect export type UptimeSpeedTestRunRow = typeof uptimeSpeedTestRuns.$inferSelect +export type SchedulerRunRow = typeof schedulerRuns.$inferSelect export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect +export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect +export type AlertGroupRow = typeof alertGroups.$inferSelect +export type AlertRuleRow = typeof alertRules.$inferSelect +export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect +export type AlertEngineStateRow = typeof alertEngineState.$inferSelect +export type AlertDestinationRow = typeof alertDestinations.$inferSelect +export type AlertHistoryRow = typeof alertHistory.$inferSelect +export type AlertOutboxRow = typeof alertOutbox.$inferSelect +export type AlertEngineCursorRow = typeof alertEngineCursor.$inferSelect diff --git a/backend/src/index.ts b/backend/src/index.ts index 9b79d3f..dd28d2a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,12 +9,15 @@ import execRoutes from "./routes/exec.js" import filtersRoutes from "./routes/filters.js" import recursiveRoutes from "./routes/recursive-routes.js" import trafficRoutes from "./routes/traffic.js" +import serversApiPingRoutes from "./routes/servers-api-ping.js" import uptimeRoutes from "./routes/uptime.js" import networkRoutes from "./routes/network.js" import evobgpRoutes from "./routes/evobgp.js" import probesRoutes from "./routes/probes.js" -import { collectTrafficOnce, restartTrafficCollector, stopTrafficCollector } from "./services/traffic-collector.js" -import { collectUptimeOnce, restartUptimeCollector, stopUptimeCollector } from "./services/uptime-collector.js" +import schedulerRoutes from "./routes/scheduler.js" +import sidebarCountsRoutes from "./routes/sidebar-counts.js" +import alertsRoutes from "./routes/alerts.js" +import { refreshScheduler, stopScheduler } from "./services/scheduler.js" // ── app factory ──────────────────────────────────────────────────────────────── @@ -49,18 +52,18 @@ await app.register(execRoutes, { prefix: "/api" }) await app.register(filtersRoutes, { prefix: "/api" }) await app.register(recursiveRoutes, { prefix: "/api" }) await app.register(trafficRoutes, { prefix: "/api" }) +await app.register(serversApiPingRoutes, { prefix: "/api" }) await app.register(uptimeRoutes, { prefix: "/api" }) await app.register(networkRoutes, { prefix: "/api" }) await app.register(evobgpRoutes, { prefix: "/api" }) await app.register(probesRoutes, { prefix: "/api" }) +await app.register(schedulerRoutes, { prefix: "/api" }) +await app.register(sidebarCountsRoutes, { prefix: "/api" }) +await app.register(alertsRoutes, { prefix: "/api" }) -restartTrafficCollector() -void collectTrafficOnce() -restartUptimeCollector() -void collectUptimeOnce() +refreshScheduler() app.addHook("onClose", async () => { - stopTrafficCollector() - stopUptimeCollector() + stopScheduler() }) // ── start ────────────────────────────────────────────────────────────────────── diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts new file mode 100644 index 0000000..f66546b --- /dev/null +++ b/backend/src/routes/alerts.ts @@ -0,0 +1,203 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { z } from "zod" +import { + getAlertsMeta, + getTelegramBotToken, + getTelegramMessageThreadIdForApi, + getTelegramPublic, + listAlertGroups, + listAlertRules, + listMergedHistory, + replaceAlertsConfig, + formatAlertRuleTestTelegramText, + sendTelegramAlertMessage, + updateTelegramSettings, +} from "../services/alerts-service.js" + +const AlertTypeSchema = z.enum([ + "gre-tunnel", + "bgp-peer", + "bgp-prefix", + "gre-client", + "server", + "rtt", + "loss", + "traffic", +]) +const AlertSeveritySchema = z.enum(["critical", "warning", "info"]) +const AlertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"]) +const RecoveryModeSchema = z.enum(["always", "never", "conditional"]) + +const AlertGroupBodySchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + combineMode: z.enum(["any", "all"]), + enabled: z.boolean(), + cooldownOverride: z.union([AlertCooldownSchema, z.null()]), +}) + +const AlertRuleBodySchema = 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(), + /** Сводка; если переданы `conditions`, может дублировать первую строку */ + condition: z.string().optional(), + /** Несколько условий (OR); приоритет над одной строкой `condition` */ + conditions: z.array(z.string().min(1)).optional(), + severity: AlertSeveritySchema, + enabled: z.boolean(), + cooldown: AlertCooldownSchema, + /** Секунды подтверждения стабильности; 0 / null / не передано — выкл. */ + 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"] }, + ) + +const PutRulesSchema = z.object({ + rules: z.array(AlertRuleBodySchema), + groups: z.array(AlertGroupBodySchema).optional(), +}) + +const PutTelegramSchema = z.object({ + /** undefined — не менять; null или "" — очистить токен */ + token: z.union([z.string(), z.null()]).optional(), + chatId: z.string().optional(), + /** undefined — не менять; null — сбросить (общий чат супергруппы) */ + messageThreadId: z.union([z.number().int().positive(), z.null()]).optional(), +}) + +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, +}) + +const TestTelegramSchema = z.object({ + /** Если не передан — взять сохранённый токен из БД */ + token: z.string().optional(), + chatId: z.string().optional(), + /** Если не передан — из БД; для проверки черновика до сохранения */ + messageThreadId: z.coerce.number().int().positive().optional(), + /** Если задан — вместо короткого текста отправляется предпросмотр правила (создание / правка). */ + rulePreview: RulePreviewTelegramSchema.optional(), +}) + +const alertsRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/alerts", async (_req, reply) => { + const tg = getTelegramPublic() + const connected = tg.tokenConfigured && Boolean(tg.chatId?.trim()) + return reply.send({ + telegram: { ...tg, connected }, + groups: listAlertGroups(), + rules: listAlertRules(), + history: listMergedHistory(), + meta: getAlertsMeta(), + }) + }) + + app.put("/alerts/rules", async (req, reply) => { + const parsed = PutRulesSchema.safeParse(req.body) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const groups = + parsed.data.groups ?? + listAlertGroups().map((g) => ({ + id: g.id, + name: g.name, + combineMode: g.combineMode, + enabled: g.enabled, + cooldownOverride: g.cooldownOverride, + })) + const rules = parsed.data.rules.map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + target: r.target, + targets: r.targets, + groupId: r.groupId ?? null, + condition: r.condition, + conditions: + r.conditions && r.conditions.length > 0 + ? r.conditions.map((c) => c.trim()).filter(Boolean) + : r.condition?.trim() + ? [r.condition.trim()] + : [], + severity: r.severity, + enabled: r.enabled, + cooldown: r.cooldown, + confirmStabilitySec: r.confirmStabilitySec ?? null, + recoveryMode: r.recoveryMode ?? "always", + recoveryStabilitySec: r.recoveryStabilitySec ?? null, + chatId: r.chatId, + })) + replaceAlertsConfig({ groups, rules }) + return reply.send({ ok: true, groups: listAlertGroups(), rules: listAlertRules() }) + }) + + app.put("/alerts/telegram", async (req, reply) => { + const parsed = PutTelegramSchema.safeParse(req.body) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const next = updateTelegramSettings({ + token: parsed.data.token, + chatId: parsed.data.chatId, + messageThreadId: parsed.data.messageThreadId, + }) + const connected = next.tokenConfigured && Boolean(next.chatId?.trim()) + return reply.send({ ok: true, telegram: { ...next, connected } }) + }) + + app.post("/alerts/telegram/test", async (req, reply) => { + const parsed = TestTelegramSchema.safeParse(req.body ?? {}) + if (!parsed.success) { + return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) + } + const fromDb = getTelegramBotToken() + const token = (parsed.data.token?.trim() || fromDb).trim() + const pub = getTelegramPublic() + const chatId = (parsed.data.chatId?.trim() || pub.chatId || "").trim() + if (!token) { + return reply.status(400).send({ error: "Не задан Bot Token (сохраните в БД или передайте в запросе)" }) + } + if (!chatId) { + return reply.status(400).send({ error: "Не задан Chat ID" }) + } + const threadId = + parsed.data.messageThreadId != null && parsed.data.messageThreadId >= 1 + ? parsed.data.messageThreadId + : getTelegramMessageThreadIdForApi() + const text = parsed.data.rulePreview + ? formatAlertRuleTestTelegramText(parsed.data.rulePreview) + : "MikroTik Manager — тест оповещений (Telegram)." + const send = await sendTelegramAlertMessage({ + text, + token: parsed.data.token?.trim() || undefined, + chatId, + messageThreadId: threadId, + }) + if (!send.ok) { + return reply.status(502).send({ ok: false, error: send.error }) + } + return reply.send({ ok: true }) + }) +} + +export default alertsRoutes diff --git a/backend/src/routes/bgp.ts b/backend/src/routes/bgp.ts index 72ddb0e..d6fbcfc 100644 --- a/backend/src/routes/bgp.ts +++ b/backend/src/routes/bgp.ts @@ -2,154 +2,10 @@ import { eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" import { servers } from "../db/schema.js" +import { parseBgpSessions } from "../services/bgp-parse-sessions.js" import { MikrotikClient } from "../services/mikrotik.js" import { ServerIdParamSchema } from "../types/server.js" -import type { RosBgpSession, BgpSessionRead } from "../types/server.js" - -type ServerRow = typeof servers.$inferSelect - -/** Parse RouterOS duration like "1m30s", "1d5h13m23s580ms", "30s" → seconds */ -function parseDuration(s: string | undefined): number { - if (!s) return 0 - let total = 0 - const matches = s.matchAll(/(\d+)(w|d|h|m(?!s)|s|ms)/g) - for (const m of matches) { - const n = parseInt(m[1]) - switch (m[2]) { - case "w": total += n * 604800; break - case "d": total += n * 86400; break - case "h": total += n * 3600; break - case "m": total += n * 60; break - case "s": total += n; break - // ms ignored for seconds precision - } - } - return total -} - -/** - * Map RouterOS 7.x short capability codes to human-readable names. - * Both local and remote capability strings are comma-separated short codes. - */ -function parseCapabilities(capStr: string | undefined): string[] { - if (!capStr) return [] - const codeMap: Record = { - mp: "MP-BGP", - rr: "Route Refresh", - gr: "Graceful Restart", - as4: "4-byte-AS", - enhe: "Extended Next-Hop", - role: "BGP Role", - err: "Extended Route Refresh", - llgr: "Long-Lived GR", - "add-path": "ADD-PATH", - } - return capStr.split(",") - .map(c => c.trim().toLowerCase()) - .filter(Boolean) - .map(c => codeMap[c] ?? c) - .filter((v, i, a) => a.indexOf(v) === i) // deduplicate -} - -/** - * Determine BGP session state from a RouterOS 7.x session object. - * - * RouterOS 7.x does NOT include a "state" field. - * Instead: "established": "true" means the session is up. - * For non-established sessions the "state" field may appear (RouterOS 6.x compat), - * or we fall back by checking other indicators. - */ -function detectState(s: RosBgpSession): string { - // RouterOS 7.x: explicit established flag - if (s["established"] === "true") return "Established" - - // RouterOS 6.x / some 7.x builds: "state" field - if (s["state"]) { - const map: Record = { - established: "Established", - active: "Active", - idle: "Idle", - connect: "Connect", - opensent: "OpenSent", - openconfirm: "OpenConfirm", - "open sent": "OpenSent", - "open confirm": "OpenConfirm", - } - const key = s["state"].trim().toLowerCase() - if (map[key]) return map[key] - // partial match - for (const [k, v] of Object.entries(map)) { - if (key.startsWith(k)) return v - } - return key.charAt(0).toUpperCase() + key.slice(1) - } - - // Has uptime but no established flag → assume degraded Active - if (s["uptime"]) return "Active" - - return "Idle" -} - -function parseSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[] { - return raw.map((s, idx) => { - const remoteAs = parseInt(s["remote.as"] ?? "0") || 0 - const localAs = parseInt(s["local.as"] ?? "0") || 0 - const type: "eBGP" | "iBGP" = (localAs > 0 && localAs === remoteAs) ? "iBGP" : "eBGP" - - // Capabilities: prefer local caps (what we negotiated); merge with remote - const localCaps = parseCapabilities(s["local.capabilities"]) - const remoteCaps = parseCapabilities(s["remote.capabilities"]) - // Old-style boolean capability fields (RouterOS 6.x) - const legacyCaps: string[] = [] - if (s["4-octet-as-capability"] === "true") legacyCaps.push("4-byte-AS") - if (s["as4-capability"] === "true") legacyCaps.push("4-byte-AS") - if (s["refresh-capability"] === "true") legacyCaps.push("Route Refresh") - if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH") - if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart") - if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages") - const caps = [...new Set([...localCaps, ...remoteCaps, ...legacyCaps])] - - // Hold time: RouterOS 7 uses "hold-time" ("1m30s"), RouterOS 6 uses "active-holdtime" (seconds) - const holdTime = parseDuration(s["hold-time"]) - || parseInt(s["active-holdtime"] ?? "0") - || 90 - - // Keepalive: may have "s" suffix - const keepalive = parseDuration(s["keepalive-time"]) - || 30 - - // Message counts: RouterOS 7 uses "remote.messages" / "local.messages" - const inputMessages = parseInt(s["remote.messages"] ?? s["total-messages-received"] ?? "0") || 0 - const outputMessages = parseInt(s["local.messages"] ?? s["total-messages-sent"] ?? "0") || 0 - - const peerIp = (s["remote.address"] ?? "").replace(/\/\d+$/, "") - - return { - id: s[".id"] ?? String(idx), - serverId: server.id, - serverName: server.name || server.host, - serverSite: server.site, - serverCountry: server.country, - name: s["name"] ?? peerIp, - peerIp, - remoteAs, - localAs, - localId: s["local.id"] ?? "", - remoteId: s["remote.id"] ?? "", - state: detectState(s), - type, - uptime: s["uptime"] || null, - holdTime, - keepalive, - prefixesRx: parseInt(s["prefix-count"] ?? s["total-updates-received"] ?? "0") || 0, - prefixesTx: parseInt(s["total-updates-sent"] ?? "0") || 0, - inputMessages, - outputMessages, - capabilities: caps, - lastError: s["last-notification"] || null, - } - }) -} +import type { BgpSessionRead } from "../types/server.js" const bgpRoutes: FastifyPluginAsyncZod = async (app) => { @@ -176,7 +32,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => { try { const client = MikrotikClient.fromServer(server) const raw = await client.getBgpSessions() - return parseSessions(server, raw) + return parseBgpSessions(server, raw) } catch { return [] } @@ -198,7 +54,7 @@ const bgpRoutes: FastifyPluginAsyncZod = async (app) => { try { const client = MikrotikClient.fromServer(server) const raw = await client.getBgpSessions() - return reply.send(parseSessions(server, raw)) + return reply.send(parseBgpSessions(server, raw)) } catch { return reply.send([]) } diff --git a/backend/src/routes/evobgp.ts b/backend/src/routes/evobgp.ts index 67eb8a4..493fe0a 100644 --- a/backend/src/routes/evobgp.ts +++ b/backend/src/routes/evobgp.ts @@ -2,7 +2,7 @@ import { eq, sql } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { z } from "zod" import { db } from "../db/index.js" -import { evobgpSettings } from "../db/schema.js" +import { evobgpSettings, filterRules } from "../db/schema.js" const PutEvobgpSchema = z.object({ baseUrl: z.string().optional(), @@ -48,7 +48,14 @@ interface EvoCatalogRaw { asns: { items: Array<{ module_id: string - entry: { id: string; asn: number; community_id?: string | null } + entry: { + id: string + asn: number + community_id?: string | null + asn_name?: string | null + prefix_count?: number | null + asn_resolved_at?: string | null + } }> } ip_ranges: { @@ -62,6 +69,60 @@ interface EvoCatalogRaw { } } +/** + * По UUID community в каталоге EvoBGP: сумма «маршрутов» — + * 1 на домен, 1 на CIDR в ip_ranges, prefix_count на ASN. + */ +function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map { + const routesPerComm = new Map() + function bump(commId: string, routeAdd: number) { + routesPerComm.set(commId, (routesPerComm.get(commId) ?? 0) + routeAdd) + } + + for (const rowItem of catalog.domains?.items ?? []) { + const cid = rowItem.entry.community_id + if (cid) bump(cid, 1) + } + for (const rowItem of catalog.ip_ranges?.items ?? []) { + const cid = rowItem.entry.community_id + if (cid) bump(cid, 1) + } + for (const rowItem of catalog.asns?.items ?? []) { + const cid = rowItem.entry.community_id + if (!cid) continue + const pc = rowItem.entry.prefix_count + const n = typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0 + bump(cid, n) + } + + return routesPerComm +} + +/** + * Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN). + * Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`). + */ +function distinctServersByFilterCommunityValue(): Map { + const rows = db + .select({ serverId: filterRules.serverId, community: filterRules.community }) + .from(filterRules) + .all() + const byVal = new Map>() + for (const r of rows) { + const v = (r.community ?? "").trim() + if (!v) continue + let set = byVal.get(v) + if (!set) { + set = new Set() + byVal.set(v, set) + } + set.add(r.serverId) + } + const out = new Map() + for (const [v, set] of byVal) out.set(v, set.size) + return out +} + function problemMessage(text: string, status: number): string { try { const j = JSON.parse(text) as { detail?: string; title?: string } @@ -236,32 +297,48 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { }) const asns = (catalog.asns?.items ?? []).map((rowItem) => { - const mod = modMap.get(rowItem.module_id) const cid = rowItem.entry.community_id + const e = rowItem.entry + const name = (e.asn_name ?? "").trim() + const pc = e.prefix_count + const prefixes = + typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0 + let updated = fmtTime + const rawResolved = (e.asn_resolved_at ?? "").trim() + if (rawResolved) { + const d = new Date(rawResolved) + if (!Number.isNaN(d.getTime())) updated = d.toLocaleString("ru-RU") + } return { - id: `${rowItem.module_id}:${rowItem.entry.id}`, - asn: `AS${rowItem.entry.asn}`, - org: "—", + id: `${rowItem.module_id}:${e.id}`, + asn: `AS${e.asn}`, + org: name || "—", country: "—", - prefixes: 0, + prefixes, filter: cid ? (commLabel.get(cid) ?? cid) : "—", - updated: fmtTime, + updated, enabled: true, } }) - const communities = commItems.map((c) => ({ - id: c.id, - value: c.community ?? c.id, - name: c.title ?? c.community ?? c.id, - description: "", - type: "custom" as const, - filterIds: [] as string[], - serverCount: 0, - prefixCount: 0, - action: "permit" as const, - enabled: true, - })) + const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog) + const serversByCommunityStr = distinctServersByFilterCommunityValue() + + const communities = commItems.map((c) => { + const valueStr = (c.community ?? "").trim() + return { + id: c.id, + value: c.community ?? c.id, + name: c.title ?? c.community ?? c.id, + description: "", + type: "custom" as const, + filterIds: [] as string[], + serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0, + prefixCount: routeCountsByCommUuid.get(c.id) ?? 0, + action: "permit" as const, + enabled: true, + } + }) return reply.send({ fetchedAt, diff --git a/backend/src/routes/filters.ts b/backend/src/routes/filters.ts index 911dbaf..7903c30 100644 --- a/backend/src/routes/filters.ts +++ b/backend/src/routes/filters.ts @@ -3,18 +3,10 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" import { filterRules, recursiveRoutes, servers } from "../db/schema.js" import { MikrotikClient } from "../services/mikrotik.js" +import { parseDbServerId } from "../utils/server-id.js" type ServerRow = typeof servers.$inferSelect -function parseServerId(raw: unknown): number | null { - const direct = Number.parseInt(String(raw ?? ""), 10) - if (Number.isFinite(direct)) return direct - const fromLegacy = String(raw ?? "").match(/(\d+)/)?.[1] - if (!fromLegacy) return null - const parsed = Number.parseInt(fromLegacy, 10) - return Number.isFinite(parsed) ? parsed : null -} - interface RosGre { ".id"?: string name?: string @@ -95,23 +87,55 @@ function parseInnerIps(rule: string | undefined): { localInnerIp: string; remote function parseFilterRule(raw: RosFilterRule): ApiFilterRule[] { const text = raw.rule ?? "" - // RouterOS rule formats vary across versions/config styles: - // - bgp-communities.has("AS:NNN") - // - bgp-communities includes AS:NNN - const communities = [ - ...text.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi), - ...text.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi), - ].map(m => m[1]).filter(Boolean) + const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim() + const out: ApiFilterRule[] = [] + const extractCommunities = (src: string): string[] => { + const communities = [ + ...src.matchAll(/bgp-communities\.has\("([^"]+)"\)/gi), + ...src.matchAll(/bgp-communities\s+includes\s+([0-9]+:[0-9]+)/gi), + ].map(m => m[1]).filter(Boolean) + return [...new Set(communities)] + } + + // Парсим по веткам if/else if, чтобы action применялся к "своим" communities. + const branches = [...text.matchAll(/(?:if|else\s+if)\s*\(([\s\S]*?)\)\s*\{([\s\S]*?)\}/gi)] + if (branches.length > 0) { + for (const b of branches) { + const condition = b[1] ?? "" + const body = b[2] ?? "" + const comms = extractCommunities(condition) + if (comms.length === 0) continue + + const isBlackhole = + /set\s+type\s+blackhole/i.test(body) || + /set\s+blackhole\s+(?:yes|true)/i.test(body) + const gwToken = body.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? "" + const outIface = body.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? "" + + for (const community of comms) { + out.push({ + id: `${raw[".id"] ?? "live"}-${out.length}`, + community, + action: isBlackhole ? "blackhole" : "route", + gateway: isBlackhole ? "" : gwToken, + gatewayTunnelId: isBlackhole ? "" : outIface, + description: inlineComment || raw.comment?.trim() || "", + }) + } + } + if (out.length > 0) return out + } + + // Fallback для старого/плоского синтаксиса без явных блоков. + const communities = extractCommunities(text) if (communities.length === 0) return [] - - const uniqueCommunities = [...new Set(communities)] - const isBlackhole = /set\s+type\s+blackhole/i.test(text) + const isBlackhole = + /set\s+type\s+blackhole/i.test(text) || + /set\s+blackhole\s+(?:yes|true)/i.test(text) const gwToken = text.match(/set\s+gw(?:ateway)?\s+([^\s;]+)/i)?.[1] ?? "" const outIface = text.match(/set\s+out-interface\s+([^\s;]+)/i)?.[1] ?? "" - const inlineComment = text.match(/#\s*([^\n]+)/)?.[1]?.trim() - - return uniqueCommunities.map((community, idx) => ({ + return communities.map((community, idx) => ({ id: `${raw[".id"] ?? "live"}-${idx}`, community, action: isBlackhole ? "blackhole" : "route", @@ -331,7 +355,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => { /** Сравнение правил в БД с живым bgp-in на MikroTik (один запрос API к роутеру) */ app.get("/filters/router-compare", async (req, reply) => { const q = req.query as { serverId?: string | number } - const serverId = parseServerId(q.serverId) + const serverId = parseDbServerId(q.serverId) if (serverId === null) { return reply.status(400).send({ error: "serverId is required" }) } @@ -369,7 +393,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => { /** GRE с роутеров: один сервер (?serverId) или все включённые (без query) — для /gre, карты сети */ app.get("/filters/gre-tunnels", async (req, reply) => { const q = req.query as { serverId?: string | number } - const sid = parseServerId(q.serverId) + const sid = parseDbServerId(q.serverId) const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() if (sid !== null) { @@ -423,7 +447,7 @@ const filtersRoutes: FastifyPluginAsyncZod = async (app) => { app.post("/filters/sync/from-router", async (_req, reply) => { const body = _req.body as { serverId?: string | number } | undefined const rawServerId = body?.serverId - const serverId = parseServerId(rawServerId) + const serverId = parseDbServerId(rawServerId) if (serverId === null) { return reply.status(400).send({ error: "serverId is required" }) } diff --git a/backend/src/routes/ospf.ts b/backend/src/routes/ospf.ts index a48203b..d02b693 100644 --- a/backend/src/routes/ospf.ts +++ b/backend/src/routes/ospf.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm" +import { and, desc, eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" -import { servers } from "../db/schema.js" +import { serverSnapshots, servers, trafficSamples, uptimeSpeedProbes } from "../db/schema.js" import { MikrotikClient } from "../services/mikrotik.js" import { ServerIdParamSchema } from "../types/server.js" import type { @@ -9,8 +9,15 @@ import type { RosBfdSession, OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionRead, } from "../types/server.js" +import { z } from "zod" type ServerRow = typeof servers.$inferSelect +type TrafficSampleRow = typeof trafficSamples.$inferSelect +type SpeedProbeRow = typeof uptimeSpeedProbes.$inferSelect + +const OspfOptimizeBodySchema = z.object({ + pingWeight: z.number().int().min(0).max(100).default(60), +}) // ── helpers ─────────────────────────────────────────────────────────────────── @@ -193,6 +200,328 @@ function parseInstances( })) } +function calcRouteScore(pingMs: number, dlMbps: number, ulMbps: number, pingWeight: number) { + const pingScore = Math.max(0, 100 - pingMs * 0.6) + const speedScore = Math.min(100, (dlMbps + ulMbps) / 18) + const w = pingWeight / 100 + return Math.round(w * pingScore + (1 - w) * speedScore) +} + +function getLatestSnapshotLatencyMs(serverId: number): number { + const latest = db + .select() + .from(serverSnapshots) + .where(eq(serverSnapshots.serverId, serverId)) + .orderBy(desc(serverSnapshots.polledAt)) + .limit(1) + .all() + return latest.length > 0 ? Math.max(1, Math.round(latest[0].latencyMs ?? 100)) : 100 +} + +function latestTrafficByInterface(serverId: number): Map { + const rows = db + .select() + .from(trafficSamples) + .where(eq(trafficSamples.serverId, serverId)) + .all() + const map = new Map() + for (const row of rows) { + const prev = map.get(row.interfaceName) + if (!prev || row.sampledAt > prev.sampledAt) map.set(row.interfaceName, row) + } + return map +} + +function normalizeSpeedMbps(row: TrafficSampleRow | undefined): { dlMbps: number; ulMbps: number } { + if (!row) return { dlMbps: 10, ulMbps: 10 } + const dl = Math.max(1, Math.round((row.rxBps ?? 0) / 1_000_000)) + const ul = Math.max(1, Math.round((row.txBps ?? 0) / 1_000_000)) + return { dlMbps: dl, ulMbps: ul } +} + +function isRefInterfaceName(name: string): boolean { + return /^\(ref\s+\*.+\)$/.test(name.trim()) +} + +function parsePingTimeMs(raw: string | undefined): number | null { + if (!raw) return null + const s = String(raw).trim().toLowerCase().replace(",", ".") + const us = s.match(/^(\d+(?:\.\d+)?)\s*us$/) + if (us) return Number.parseFloat(us[1]) / 1000 + const ms = s.match(/^(\d+(?:\.\d+)?)\s*ms$/) + if (ms) return Number.parseFloat(ms[1]) + const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/) + if (sec) return Number.parseFloat(sec[1]) * 1000 + const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/) + if (clock) { + const h = Number.parseFloat(clock[1]) + const m = Number.parseFloat(clock[2]) + const sc = Number.parseFloat(clock[3]) + return (h * 3600 + m * 60 + sc) * 1000 + } + return null +} + +function avgPingMsFromResults(rows: Array<{ time?: string; status?: string }>): number | null { + const ok = rows.filter((r) => r.time && String(r.status ?? "").toLowerCase() !== "timeout") + const vals = ok + .map((r) => parsePingTimeMs(r.time)) + .filter((v): v is number => v != null && Number.isFinite(v)) + if (!vals.length) return null + return Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)) +} + +async function measurePingByInterface( + client: MikrotikClient, + ifaceName: string, + targetAddr: string | undefined, + fallbackMs: number, +): Promise { + if (!targetAddr) return fallbackMs + try { + const rows = await client.ping(targetAddr, 2, ifaceName, { interval: "0.2s" }) + return avgPingMsFromResults(rows) ?? fallbackMs + } catch { + return fallbackMs + } +} + +type OspfRankedInterface = { + id: string + interface: string + currentCost: number + pingMs: number + dlMbps: number + ulMbps: number + score: number + optimalCost: number +} + +async function buildOspfOptimizationPlan(server: ServerRow, pingWeight: number): Promise { + const { ifaceTemplates, areas, instances, neighbors } = await fetchServerOspf(server) + const areaMap = buildAreaMap(areas) + const parsed = parseInterfaces(server, ifaceTemplates, areas, instances, areaMap) + const editable = parsed.filter((i) => !i.disabled && !isRefInterfaceName(i.interface)) + const localLatencyMs = getLatestSnapshotLatencyMs(server.id) + const latestTraffic = latestTrafficByInterface(server.id) + + // 1) Сопоставляем remote OSPF router-id -> сервер из каталога (чтобы взять унифицированный ping как в карте /route-optimizer). + const neededRouterIds = new Set(neighbors.map((n) => String(n["router-id"] ?? "")).filter((x) => x.length > 0)) + + async function buildRouterIdToServerMap(needed: Set): Promise> { + const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() + const out = new Map() + const neededLeft = new Set(needed) + + for (const s of enabledServers) { + if (neededLeft.size === 0) break + try { + const c = MikrotikClient.fromServer(s) + const inst = await c.getOspfInstances() + for (const i of inst) { + const rid = String(i["router-id"] ?? "").trim() + if (!rid) continue + if (neededLeft.has(rid)) { + out.set(rid, s.id) + neededLeft.delete(rid) + } + } + } catch { + // ignore per-server errors, preview should still work + } + } + return out + } + + const routerIdToServerId = await buildRouterIdToServerMap(neededRouterIds) + + // 2) Сначала выбираем remote serverId для каждого OSPF интерфейса (по neighbor router-id). + function pickBestNeighborForInterface(iface: string) { + const cand = neighbors.filter((n) => n.interface === iface) + if (!cand.length) return null + const rank = (st: string) => (st === "Full" ? 100 : st === "2-Way" ? 60 : 30) + return cand.sort((a, b) => rank(b.state) - rank(a.state))[0] ?? null + } + + const remoteServerIds = new Set() + const remoteForIface = new Map() + for (const iface of editable) { + const n = pickBestNeighborForInterface(iface.interface) + if (!n) continue + const remoteServerId = routerIdToServerId.get(String(n["router-id"] ?? "").trim()) + if (remoteServerId != null) { + remoteServerIds.add(remoteServerId) + remoteForIface.set(iface.interface, remoteServerId) + } + } + + // 3) Предгружаем uptimeSpeedProbes: оттуда берём и ping, и скорость (единый источник как у карты). + const allSourceProbes = db + .select() + .from(uptimeSpeedProbes) + .where(eq(uptimeSpeedProbes.srcServerId, server.id)) + .all() + .filter((r: SpeedProbeRow) => r.enabled !== false) + const speedProbesByDest = new Map() + for (const p of allSourceProbes) { + const arr = speedProbesByDest.get(p.dstServerId) ?? [] + arr.push(p) + speedProbesByDest.set(p.dstServerId, arr) + } + + function normIface(s: string) { + return s.trim().toLowerCase() + } + + function pickBestSpeedProbe(probes: SpeedProbeRow[], ifaceName: string): SpeedProbeRow | undefined { + if (probes.length === 0) return undefined + const scoreProbeForIface = (p: SpeedProbeRow, targetIface: string): number => { + const got = normIface(p.srcInterface) + const target = normIface(targetIface) + // Сверяемся с /gre: если у пробы есть srcInterface и у OSPF iface есть имя, + // допускаем только точное совпадение интерфейса. + if (got && target && got !== target) return -1 + const ifaceScore = + got && target && got === target ? 100 + : (!got && target) ? 35 + : (!target && got) ? 15 + : 5 + const freshness = + p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0 + return ifaceScore + + (p.lastPingRttMs != null ? 25 : 0) + + (p.lastTxAvgMbps != null ? 12 : 0) + + (p.lastRxAvgMbps != null ? 12 : 0) + + freshness + } + + let best: SpeedProbeRow | undefined + let bestScore = -1 + for (const p of probes) { + const s = scoreProbeForIface(p, ifaceName) + if (s > bestScore) { + bestScore = s + best = p + } + } + return best + } + + function assignProbesUniquely( + ifaceNames: string[], + probes: SpeedProbeRow[], + ): Map { + const assigned = new Map() + if (ifaceNames.length === 0 || probes.length === 0) return assigned + + const scoreProbeForIface = (p: SpeedProbeRow, ifaceName: string): number => { + const got = normIface(p.srcInterface) + const target = normIface(ifaceName) + if (got && target && got !== target) return -1 + const ifaceScore = + got && target && got === target ? 100 + : (!got && target) ? 35 + : (!target && got) ? 15 + : 5 + const freshness = + p.lastPingAt ? Math.max(0, 20 - Math.floor((Date.now() - Date.parse(p.lastPingAt)) / (60 * 60 * 1000))) : 0 + return ifaceScore + + (p.lastPingRttMs != null ? 25 : 0) + + (p.lastTxAvgMbps != null ? 12 : 0) + + (p.lastRxAvgMbps != null ? 12 : 0) + + freshness + } + + const leftIfaces = new Set(ifaceNames) + const usedProbeIds = new Set() + + while (leftIfaces.size > 0) { + let bestIface: string | null = null + let bestProbe: SpeedProbeRow | null = null + let bestScore = -1 + + for (const iface of leftIfaces) { + for (const p of probes) { + if (usedProbeIds.has(p.id)) continue + const s = scoreProbeForIface(p, iface) + if (s > bestScore) { + bestScore = s + bestIface = iface + bestProbe = p + } + } + } + + if (!bestIface || !bestProbe) break + assigned.set(bestIface, bestProbe) + leftIfaces.delete(bestIface) + usedProbeIds.add(bestProbe.id) + } + + // Если проб меньше чем интерфейсов — добираем лучшими совпадениями (reuse допустим). + for (const iface of leftIfaces) { + const fallback = pickBestSpeedProbe(probes, iface) + if (fallback) assigned.set(iface, fallback) + } + + return assigned + } + + const assignedProbeByIface = new Map() + const ifacesByDst = new Map() + for (const iface of editable) { + const dst = remoteForIface.get(iface.interface) ?? null + const arr = ifacesByDst.get(dst) ?? [] + arr.push(iface.interface) + ifacesByDst.set(dst, arr) + } + for (const [dst, ifaceList] of ifacesByDst) { + const probesByDst = dst != null ? speedProbesByDest.get(dst) ?? [] : [] + const pool = probesByDst.length > 0 ? probesByDst : allSourceProbes + const assigned = assignProbesUniquely(ifaceList, pool) + for (const [iface, probe] of assigned.entries()) assignedProbeByIface.set(iface, probe) + } + + return editable + .map((iface) => { + const t = latestTraffic.get(iface.interface) + const speed = normalizeSpeedMbps(t) + + const dstServerId = remoteForIface.get(iface.interface) + const bestProbe = assignedProbeByIface.get(iface.interface) + + const pingMs = + bestProbe?.lastPingRttMs != null + ? Math.max(1, Math.round(bestProbe.lastPingRttMs)) + : dstServerId != null + ? Math.min(995, Math.round(localLatencyMs + getLatestSnapshotLatencyMs(dstServerId))) + : localLatencyMs + + const dlMbps = + bestProbe?.lastTxAvgMbps != null + ? Math.max(1, Math.round(bestProbe.lastTxAvgMbps)) + : speed.dlMbps + + const ulMbps = + bestProbe?.lastRxAvgMbps != null + ? Math.max(1, Math.round(bestProbe.lastRxAvgMbps)) + : speed.ulMbps + + return { + id: iface.id, + interface: iface.interface, + currentCost: iface.cost, + pingMs, + dlMbps, + ulMbps, + score: calcRouteScore(pingMs, dlMbps, ulMbps, pingWeight), + optimalCost: 0, + } + }) + .sort((a, b) => b.score - a.score || a.interface.localeCompare(b.interface)) + .map((row, idx) => ({ ...row, optimalCost: (idx + 1) * 10 })) +} + // ── route plugin ────────────────────────────────────────────────────────────── const ospfRoutes: FastifyPluginAsyncZod = async (app) => { @@ -322,6 +651,75 @@ const ospfRoutes: FastifyPluginAsyncZod = async (app) => { return reply.status(500).send({ error: String(err) }) } }) + + // POST /api/servers/:id/ospf/optimize — compute + apply optimal OSPF cost for active templates + app.post( + "/servers/:id/ospf/optimize/preview", + { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, + async (req, reply) => { + const server = db + .select().from(servers) + .where(eq(servers.id, req.params.id)) + .limit(1).all()[0] + + if (!server) return reply.status(404).send({ error: "Server not found" }) + + try { + const pingWeight = req.body.pingWeight + const ranked = await buildOspfOptimizationPlan(server, pingWeight) + const changed = ranked.filter((r) => r.currentCost !== r.optimalCost) + return reply.send({ + serverId: server.id, + serverName: server.name || server.host, + pingWeight, + interfacesTotal: ranked.length, + changedCount: changed.length, + unchangedCount: ranked.length - changed.length, + interfaces: ranked, + changes: changed, + }) + } catch (err) { + return reply.status(500).send({ error: String(err) }) + } + }, + ) + + app.post( + "/servers/:id/ospf/optimize", + { schema: { params: ServerIdParamSchema, body: OspfOptimizeBodySchema } }, + async (req, reply) => { + const server = db + .select().from(servers) + .where(eq(servers.id, req.params.id)) + .limit(1).all()[0] + + if (!server) return reply.status(404).send({ error: "Server not found" }) + + try { + const pingWeight = req.body.pingWeight + const ranked = await buildOspfOptimizationPlan(server, pingWeight) + + const client = MikrotikClient.fromServer(server) + const changed = ranked.filter((r) => r.currentCost !== r.optimalCost) + const applied: Array<{ interface: string; from: number; to: number }> = [] + for (const item of changed) { + await client.setOspfInterfaceTemplateCost(item.id, item.optimalCost) + applied.push({ interface: item.interface, from: item.currentCost, to: item.optimalCost }) + } + + return reply.send({ + serverId: server.id, + serverName: server.name || server.host, + pingWeight, + optimizedCount: changed.length, + applied, + interfaces: ranked, + }) + } catch (err) { + return reply.status(500).send({ error: String(err) }) + } + }, + ) } export default ospfRoutes diff --git a/backend/src/routes/recursive-routes.ts b/backend/src/routes/recursive-routes.ts index 635d135..39caa8b 100644 --- a/backend/src/routes/recursive-routes.ts +++ b/backend/src/routes/recursive-routes.ts @@ -3,6 +3,7 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { db } from "../db/index.js" import { recursiveRoutes, servers } from "../db/schema.js" import { MikrotikClient } from "../services/mikrotik.js" +import { parseDbServerId } from "../utils/server-id.js" type ServerRow = typeof servers.$inferSelect @@ -46,15 +47,6 @@ interface RecursiveRouteDto { disabled: boolean } -function parseServerId(raw: unknown): number | null { - const direct = Number.parseInt(String(raw ?? ""), 10) - if (Number.isFinite(direct)) return direct - const legacy = String(raw ?? "").match(/(\d+)/)?.[1] - if (!legacy) return null - const parsed = Number.parseInt(legacy, 10) - return Number.isFinite(parsed) ? parsed : null -} - function isIpGateway(gw: string): boolean { return /^\d{1,3}(\.\d{1,3}){3}(?:%\S+)?$/.test(gw.trim()) } @@ -150,7 +142,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) { const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { app.get("/recursive-routes/gateways", async (req, reply) => { const q = req.query as { serverId?: string | number } - const serverId = parseServerId(q.serverId) + const serverId = parseDbServerId(q.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) @@ -180,7 +172,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { app.get("/recursive-routes", async (req, reply) => { const q = req.query as { serverId?: string | number } - const serverId = parseServerId(q.serverId) + const serverId = parseDbServerId(q.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) @@ -189,7 +181,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { app.put("/recursive-routes", async (req, reply) => { const body = req.body as { serverId?: string | number; routes?: RecursiveRouteDto[] } - const serverId = parseServerId(body.serverId) + const serverId = parseDbServerId(body.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) @@ -199,7 +191,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { app.post("/recursive-routes/sync/from-router", async (req, reply) => { const body = req.body as { serverId?: string | number } | undefined - const serverId = parseServerId(body?.serverId) + const serverId = parseDbServerId(body?.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) const server: ServerRow | undefined = db .select().from(servers) @@ -235,7 +227,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { app.post("/recursive-routes/sync/to-router", async (req, reply) => { const body = req.body as { serverId?: string | number } | undefined - const serverId = parseServerId(body?.serverId) + const serverId = parseDbServerId(body?.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) const server = db.select().from(servers).where(eq(servers.id, serverId)).limit(1).all()[0] if (!server) return reply.status(404).send({ error: "Server not found" }) diff --git a/backend/src/routes/scheduler.ts b/backend/src/routes/scheduler.ts new file mode 100644 index 0000000..42762d3 --- /dev/null +++ b/backend/src/routes/scheduler.ts @@ -0,0 +1,53 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { + getSchedulerStatus, + JOB_KEYS, + listSchedulerRuns, + refreshScheduler, + runSchedulerJobNow, +} from "../services/scheduler.js" + +function parseJobKey(raw: string | undefined): typeof JOB_KEYS[number] | null { + const s = String(raw ?? "").trim() + return (JOB_KEYS as readonly string[]).includes(s) ? (s as typeof JOB_KEYS[number]) : null +} + +const schedulerRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/scheduler/status", async (_req, reply) => { + return reply.send(getSchedulerStatus()) + }) + + app.get("/scheduler/runs", async (req, reply) => { + const q = req.query as { jobKey?: string; limit?: string; offset?: string } + const jobKey = q.jobKey ? parseJobKey(q.jobKey) : undefined + if (q.jobKey && jobKey == null) { + return reply.status(400).send({ error: "Некорректный jobKey" }) + } + const limit = Math.min(200, Math.max(1, Number.parseInt(String(q.limit ?? "50"), 10) || 50)) + const offset = Math.max(0, Number.parseInt(String(q.offset ?? "0"), 10) || 0) + return reply.send(listSchedulerRuns({ jobKey: jobKey ?? undefined, limit, offset })) + }) + + app.post("/scheduler/refresh", async (_req, reply) => { + refreshScheduler() + return reply.send({ ok: true, status: getSchedulerStatus() }) + }) + + app.post("/scheduler/jobs/:jobKey/run-now", async (req, reply) => { + const jobKey = parseJobKey(String((req.params as { jobKey?: string }).jobKey ?? "")) + if (!jobKey) return reply.status(400).send({ error: "Некорректный jobKey" }) + try { + await runSchedulerJobNow(jobKey) + return reply.send({ ok: true, status: getSchedulerStatus() }) + } catch (e) { + const statusCode = (e as Error & { statusCode?: number }).statusCode + if (statusCode === 409) { + return reply.status(409).send({ error: "Задача уже выполняется" }) + } + const msg = e instanceof Error ? e.message : String(e) + return reply.status(500).send({ error: msg }) + } + }) +} + +export default schedulerRoutes diff --git a/backend/src/routes/servers-api-ping.ts b/backend/src/routes/servers-api-ping.ts new file mode 100644 index 0000000..05b9c68 --- /dev/null +++ b/backend/src/routes/servers-api-ping.ts @@ -0,0 +1,62 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js" +import { refreshScheduler } from "../services/scheduler.js" +import { + collectServersRestPingOnce, + getServersApiPingSettings, + getServersRestPingCollectorState, + updateServersApiPingSettings, +} from "../services/servers-rest-ping-collector.js" + +const serversApiPingRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/servers-api-ping/settings", async (_req, reply) => { + const settings = getServersApiPingSettings() + const state = getServersRestPingCollectorState() + return reply.send({ + enabled: settings.enabled, + intervalSec: settings.intervalSec, + lastCollectedAt: settings.lastCollectedAt ?? null, + lastDurationMs: settings.lastDurationMs ?? null, + lastError: settings.lastError || null, + collectorRunning: state.running, + }) + }) + + app.put("/servers-api-ping/settings", async (req, reply) => { + const body = req.body as { + enabled?: boolean + intervalSec?: number | string + } + const intervalSec = + body.intervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.intervalSec), 10) || 120) + const updated = updateServersApiPingSettings({ + enabled: body.enabled, + intervalSec, + }) + refreshScheduler() + return reply.send({ + ok: true, + settings: { + enabled: updated.enabled, + intervalSec: updated.intervalSec, + lastCollectedAt: updated.lastCollectedAt ?? null, + lastDurationMs: updated.lastDurationMs ?? null, + lastError: updated.lastError || null, + }, + }) + }) + + app.post("/servers-api-ping/collect-now", async (_req, reply) => { + await collectServersRestPingOnce() + scheduleAlertEngineAfterDataCollectors() + const row = getServersApiPingSettings() + return reply.send({ + ok: true, + lastCollectedAt: row.lastCollectedAt ?? null, + lastDurationMs: row.lastDurationMs ?? null, + lastError: row.lastError || null, + }) + }) +} + +export default serversApiPingRoutes diff --git a/backend/src/routes/sidebar-counts.ts b/backend/src/routes/sidebar-counts.ts new file mode 100644 index 0000000..5c389e5 --- /dev/null +++ b/backend/src/routes/sidebar-counts.ts @@ -0,0 +1,38 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { db } from "../db/index.js" +import { + filterRules, + recursiveRoutes, + servers, + uptimeProbes, + uptimeSpeedProbes, +} from "../db/schema.js" + +const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => { + app.get("/sidebar-counts", async (_req, reply) => { + const [ + serversTotal, + filterRulesTotal, + uptimeProbesTotal, + uptimeSpeedProbesTotal, + recursiveRoutesTotal, + ] = [ + db.select().from(servers).all().length, + db.select().from(filterRules).all().length, + db.select().from(uptimeProbes).all().length, + db.select().from(uptimeSpeedProbes).all().length, + db.select().from(recursiveRoutes).all().length, + ] + + return reply.send({ + servers: serversTotal, + filterRules: filterRulesTotal, + uptimeProbes: uptimeProbesTotal, + uptimeSpeedProbes: uptimeSpeedProbesTotal, + monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal, + recursiveRoutes: recursiveRoutesTotal, + }) + }) +} + +export default sidebarCountsRoutes diff --git a/backend/src/routes/traffic.ts b/backend/src/routes/traffic.ts index 64fd126..f8271a3 100644 --- a/backend/src/routes/traffic.ts +++ b/backend/src/routes/traffic.ts @@ -9,6 +9,8 @@ import { readServerSamplesInRange, updateTrafficSettings, } from "../services/traffic-collector.js" +import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js" +import { refreshScheduler } from "../services/scheduler.js" type SnapshotRow = typeof serverSnapshots.$inferSelect @@ -189,6 +191,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => { intervalSec, retentionDays, }) + refreshScheduler() return reply.send({ ok: true, settings: { @@ -204,6 +207,7 @@ const trafficRoutes: FastifyPluginAsyncZod = async (app) => { app.post("/traffic/collect-now", async (_req, reply) => { await collectTrafficOnce() + scheduleAlertEngineAfterDataCollectors() const updated = getTrafficSettings() return reply.send({ ok: true, diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index eb6adf2..b078092 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -3,10 +3,11 @@ import { asc, desc, eq } from "drizzle-orm" import { db } from "../db/index.js" import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js" import { MikrotikClient } from "../services/mikrotik.js" -import type { RosIpAddress } from "../types/server.js" +import { scheduleAlertEngineAfterDataCollectors } from "../services/alert-collector-hooks.js" +import { refreshScheduler, getSchedulerStatus } from "../services/scheduler.js" import { + collectPingForProbeIds, collectUptimeOnce, - parsePing, readProbeRows, readProbeSamplesSince, readResourceSamplesSince, @@ -15,6 +16,7 @@ import { updateProbeShowOnDashboard, updateUptimeSettings, } from "../services/uptime-collector.js" +import { recordSpeedTestFailure, runBandwidthSpeedTest } from "../services/uptime-speed-service.js" function rangeToMinutes(range: string | undefined): number { switch ((range ?? "1h").toLowerCase()) { @@ -34,69 +36,33 @@ function toSeries(values: number[], target = 40): number[] { return [...Array(target - values.length).fill(values[0] ?? 0), ...values] } +/** Сэмпл ресурсов из SQLite (readResourceSamplesSince). */ +type ResourceSampleRow = ReturnType[number] + /** - * Один «рабочий» IPv4/IPv6 для интерфейса: пропускаем записи /ip/address с disabled/invalid, - * среди оставшихся предпочитаем статические адреса динамическим (иначе БТ может цепляться к отключённому IP). + * Последний сэмпл по времени может быть offline после сбоя опроса — тогда UI «пустой». + * Берём последний успешный (online + RAM), только если он не слишком старый относительно последней попытки, + * иначе снова показываем устаревший uptime/метрики как раньше. */ -function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string { - const name = iface.trim() - const candidates = rows.filter((a) => { - if (String(a.interface ?? "").trim() !== name) return false - if (String(a.disabled ?? "false").toLowerCase() === "true") return false - if (String(a.invalid ?? "false").toLowerCase() === "true") return false - const addr = String(a.address ?? "").trim() - return addr.includes("/") - }) - candidates.sort((a, b) => { - const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0 - const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0 - return ad - bd - }) - const raw = candidates[0]?.address - return raw ? String(raw).split("/")[0]?.trim() ?? "" : "" -} +function pickResourceDisplayRow(rows: ResourceSampleRow[], maxGapMs: number): { + row: ResourceSampleRow | undefined + hasData: boolean +} { + if (rows.length === 0) return { row: undefined, hasData: false } + const last = rows[rows.length - 1]! + const lastTs = Date.parse(last.sampledAt) + const validOnline = (r: ResourceSampleRow) => r.status === "online" && r.totalMemory > 0 -function parseRateToMbps(raw: unknown): number { - const txt = String(raw ?? "").trim().toLowerCase() - if (!txt) return 0 - const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/) - if (m) { - const n = Number.parseFloat(m[1]) - const u = m[2] ?? "" - if (!Number.isFinite(n)) return 0 - if (u === "g") return n * 1000 - if (u === "m") return n - if (u === "k") return n / 1000 - return n / 1_000_000 + if (validOnline(last)) { + return { row: last, hasData: true } } - const n = Number.parseFloat(txt.replace(/[^\d.]/g, "")) - if (!Number.isFinite(n)) return 0 - return n > 10000 ? n / 1_000_000 : n -} -const btestNodeLocks = new Map>() + const fallback = [...rows].reverse().find(validOnline) + if (!fallback) return { row: undefined, hasData: false } -async function withBtestNodeLocks(nodeIds: number[], fn: () => Promise): Promise { - const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b) - const releases: Array<() => void> = [] - try { - for (const id of ids) { - const prev = btestNodeLocks.get(id) - let resolveCurrent!: () => void - const current = new Promise((resolve) => { - resolveCurrent = resolve - }) - btestNodeLocks.set(id, current) - if (prev) await prev - releases.push(() => { - if (btestNodeLocks.get(id) === current) btestNodeLocks.delete(id) - resolveCurrent() - }) - } - return await fn() - } finally { - for (let i = releases.length - 1; i >= 0; i -= 1) releases[i]() - } + const fbTs = Date.parse(fallback.sampledAt) + if (lastTs - fbTs <= maxGapMs) return { row: fallback, hasData: true } + return { row: undefined, hasData: false } } const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { @@ -104,19 +70,28 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { const s = readUptimeSettings() return reply.send({ enabled: s.enabled, + resourcesEnabled: s.resourcesEnabled ?? s.enabled, + pingEnabled: s.pingEnabled ?? s.enabled, + speedEnabled: s.speedEnabled ?? s.enabled, + /** Интервал сбора метрик ресурсов (сек). */ intervalSec: s.intervalSec, - probeIntervalSec: s.probeIntervalSec ?? s.intervalSec, + /** Интервал тика ping и значение по умолчанию для проб без своего interval. */ + probeIntervalSec: s.probeIntervalSec, speedIntervalSec: s.speedIntervalSec ?? 60, retentionDays: s.retentionDays, lastCollectedAt: s.lastCollectedAt ?? null, lastDurationMs: s.lastDurationMs ?? null, lastError: s.lastError || null, + scheduler: getSchedulerStatus(), }) }) app.put("/uptime/settings", async (req, reply) => { const body = req.body as { enabled?: boolean + resourcesEnabled?: boolean + pingEnabled?: boolean + speedEnabled?: boolean intervalSec?: number | string probeIntervalSec?: number | string speedIntervalSec?: number | string @@ -124,28 +99,37 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { } const updated = updateUptimeSettings({ enabled: body.enabled, - intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 15), + resourcesEnabled: body.resourcesEnabled, + pingEnabled: body.pingEnabled, + speedEnabled: body.speedEnabled, + intervalSec: body.intervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.intervalSec), 10) || 300), probeIntervalSec: body.probeIntervalSec == null ? undefined : Math.max(5, Number.parseInt(String(body.probeIntervalSec), 10) || 15), speedIntervalSec: body.speedIntervalSec == null ? undefined : Math.max(10, Number.parseInt(String(body.speedIntervalSec), 10) || 60), retentionDays: body.retentionDays == null ? undefined : Math.max(1, Number.parseInt(String(body.retentionDays), 10) || 14), }) + refreshScheduler() return reply.send({ ok: true, settings: { enabled: updated.enabled, + resourcesEnabled: updated.resourcesEnabled ?? updated.enabled, + pingEnabled: updated.pingEnabled ?? updated.enabled, + speedEnabled: updated.speedEnabled ?? updated.enabled, intervalSec: updated.intervalSec, - probeIntervalSec: updated.probeIntervalSec ?? updated.intervalSec, + probeIntervalSec: updated.probeIntervalSec, speedIntervalSec: updated.speedIntervalSec ?? 60, retentionDays: updated.retentionDays, lastCollectedAt: updated.lastCollectedAt ?? null, lastDurationMs: updated.lastDurationMs ?? null, lastError: updated.lastError || null, + scheduler: getSchedulerStatus(), }, }) }) app.post("/uptime/collect-now", async (_req, reply) => { await collectUptimeOnce() + scheduleAlertEngineAfterDataCollectors() const s = readUptimeSettings() return reply.send({ ok: true, @@ -155,8 +139,42 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { }) }) + /** Ручной ping по списку id проб (группа «сервер + назначение» на UI); без ожидания interval_sec. */ + app.post("/uptime/probes/collect-group", async (req, reply) => { + const body = req.body as { probeIds?: unknown } + const raw = body.probeIds + const probeIds = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [] + try { + const { polled } = await collectPingForProbeIds(probeIds) + if (polled > 0) scheduleAlertEngineAfterDataCollectors() + return reply.send({ ok: true, polled }) + } catch (e) { + const status = (e as Error & { statusCode?: number }).statusCode + if (status === 409) { + return reply.status(409).send({ error: e instanceof Error ? e.message : "busy" }) + } + throw e + } + }) + app.get("/uptime/probes", async (_req, reply) => { - return reply.send({ probes: readProbeRows() }) + const rows = readProbeRows() + return reply.send({ + probes: rows.map((p) => ({ + id: p.id, + srcServerId: String(p.srcServerId), + srcInterface: p.srcInterface || "", + name: p.name, + target: p.target, + probeFilter: p.probeFilter || "—", + enabled: p.enabled, + intervalSec: p.intervalSec ?? 0, + showOnDashboard: p.showOnDashboard, + sortOrder: p.sortOrder, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + })), + }) }) app.get("/uptime/speed-probes", async (_req, reply) => { @@ -275,6 +293,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { createdAt: now, }).run() } + refreshScheduler() return reply.send({ ok: true }) }) @@ -289,6 +308,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { filter?: string enabled?: boolean showOnDashboard?: boolean + intervalSec?: number | string }> } const normalized = (body.probes ?? []) @@ -301,9 +321,13 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { probeFilter: String(p.filter ?? "—"), enabled: p.enabled !== false, showOnDashboard: p.showOnDashboard === true, + intervalSec: p.intervalSec == null || p.intervalSec === "" + ? undefined + : Number.parseInt(String(p.intervalSec), 10), })) .filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target) replaceProbes(normalized) + refreshScheduler() return reply.send({ ok: true }) }) @@ -366,186 +390,37 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0] if (!src || !dst) return reply.status(404).send({ error: "Server not found" }) try { - return await withBtestNodeLocks([srcId, dstId], async () => { - const protocol = body.protocol === "udp" ? "udp" : "tcp" - const direction = body.direction === "transmit" || body.direction === "receive" ? body.direction : "both" - const durationSec = Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10) - const srcInterface = String(body.srcInterface ?? "").trim() - const dstInterface = String(body.dstInterface ?? "").trim() - const srcClient = MikrotikClient.fromServer(src) - const dstAddressRows = dstInterface - ? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => []) - : [] - const srcAddressRows = srcInterface - ? await srcClient.getIpAddresses().catch(() => []) - : [] - const dstAddress = dstInterface ? firstUsableIpOnInterface(dstAddressRows, dstInterface) : dst.host - if (dstInterface && !dstAddress) { - return reply.status(400).send({ - error: `На интерфейсе назначения '${dstInterface}' нет активного IP (проверьте /ip/address — не disabled).`, - }) - } - const initialAddress = dstAddress || dst.host - const srcAddress = srcInterface ? firstUsableIpOnInterface(srcAddressRows, srcInterface) : src.host - const srcInterfaceAddress = srcInterface ? (srcAddress || null) : null - const dstInterfaceAddress = dstInterface ? (dstAddress || null) : null - - const rows = await srcClient.bandwidthTest({ - address: initialAddress, - user: dst.username, - password: dst.password, - protocol, - direction, - durationSec, - }) - const txSeries = rows - .map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"])) - .filter((v) => Number.isFinite(v) && v >= 0) - const rxSeries = rows - .map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"])) - .filter((v) => Number.isFinite(v) && v >= 0) - const last = rows[rows.length - 1] ?? {} - const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"]) - const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"]) - const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0) - const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0) - - await new Promise((resolve) => setTimeout(resolve, 3000)) - - const pingNow = new Date().toISOString() - let afterBtPing: { - rttMs: number | null - lossPct: number | null - error: string | null - } = { rttMs: null, lossPct: null, error: null } - try { - const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" }) - const pr = parsePing(pingRows as Parameters[0]) - afterBtPing = { - rttMs: pr.avgRtt, - lossPct: pr.loss, - error: null, - } - } catch (pe) { - afterBtPing = { - rttMs: null, - lossPct: null, - error: pe instanceof Error ? pe.message : String(pe), - } - } - - const probeId = String(body.probeId ?? "").trim() - const runId = String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - if (probeId) { - db.update(uptimeSpeedProbes).set({ - lastRunAt: pingNow, - lastTxAvgMbps: txFinal, - lastRxAvgMbps: rxFinal, - lastStatus: "done", - lastError: "", - lastPingRttMs: afterBtPing.rttMs, - lastPingLossPct: afterBtPing.lossPct, - lastPingAt: pingNow, - lastPingError: afterBtPing.error ?? "", - updatedAt: pingNow, - }).where(eq(uptimeSpeedProbes.id, probeId)).run() - } - db.insert(uptimeSpeedTestRuns).values({ - id: runId, - probeId: probeId || null, - srcServerId: srcId, - dstServerId: dstId, - srcInterface: srcInterface || "", - dstInterface: dstInterface || "", - srcAddress: srcAddress || src.host, - dstAddress: initialAddress, - srcInterfaceAddress, - dstInterfaceAddress, - protocol, - direction, - durationSec, - txAvgMbps: txFinal, - rxAvgMbps: rxFinal, - pingRttMs: afterBtPing.rttMs, - pingLossPct: afterBtPing.lossPct, - pingError: afterBtPing.error ?? null, - status: "done", - error: null, - createdAt: pingNow, - }).run() - return reply.send({ - ok: true, - result: { - runId, - srcInterface: srcInterface || null, - dstInterface: dstInterface || null, - address: initialAddress, - srcAddress: srcAddress || src.host, - dstAddress: initialAddress, - srcInterfaceAddress, - dstInterfaceAddress, - protocol, - direction, - durationSec, - txAvgMbps: txFinal, - rxAvgMbps: rxFinal, - txSeries, - rxSeries, - raw: rows, - afterBtPing, - }, - }) + const result = await runBandwidthSpeedTest({ + runId: String(body.runId ?? "").trim() || undefined, + probeId: String(body.probeId ?? "").trim() || undefined, + srcServerId: srcId, + dstServerId: dstId, + srcInterface: String(body.srcInterface ?? "").trim() || undefined, + dstInterface: String(body.dstInterface ?? "").trim() || undefined, + protocol: body.protocol === "udp" ? "udp" : "tcp", + direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both", + durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10), }) + return reply.send({ ok: true, result }) } catch (e) { const raw = e instanceof Error ? e.message : "Bandwidth test failed" + recordSpeedTestFailure({ + probeId: String(body.probeId ?? "").trim() || undefined, + runId: String(body.runId ?? "").trim() || undefined, + srcServerId: srcId, + dstServerId: dstId, + srcInterface: String(body.srcInterface ?? "").trim(), + dstInterface: String(body.dstInterface ?? "").trim(), + protocol: body.protocol === "udp" ? "udp" : "tcp", + direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both", + durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10), + message: raw, + }) const message = /timed out|socket hang up|ECONNRESET/i.test(raw) ? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов" : raw - const probeId = String((req.body as { probeId?: string }).probeId ?? "").trim() - if (probeId) { - db.update(uptimeSpeedProbes).set({ - lastRunAt: new Date().toISOString(), - lastStatus: "error", - lastError: message, - updatedAt: new Date().toISOString(), - }).where(eq(uptimeSpeedProbes.id, probeId)).run() - } - const body = req.body as { - runId?: string - srcServerId?: string | number - dstServerId?: string | number - srcInterface?: string - dstInterface?: string - protocol?: "tcp" | "udp" - direction?: "transmit" | "receive" | "both" - durationSec?: string | number - } - const srcId = Number.parseInt(String(body.srcServerId ?? ""), 10) - const dstId = Number.parseInt(String(body.dstServerId ?? ""), 10) - if (Number.isFinite(srcId) && Number.isFinite(dstId)) { - db.insert(uptimeSpeedTestRuns).values({ - id: String(body.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - probeId: probeId || null, - srcServerId: srcId, - dstServerId: dstId, - srcInterface: String(body.srcInterface ?? "").trim(), - dstInterface: String(body.dstInterface ?? "").trim(), - srcAddress: null, - dstAddress: null, - srcInterfaceAddress: null, - dstInterfaceAddress: null, - protocol: body.protocol === "udp" ? "udp" : "tcp", - direction: body.direction === "transmit" || body.direction === "receive" ? body.direction : "both", - durationSec: Math.max(3, Number.parseInt(String(body.durationSec ?? ""), 10) || 10), - txAvgMbps: null, - rxAvgMbps: null, - pingRttMs: null, - pingLossPct: null, - pingError: null, - status: "error", - error: message, - createdAt: new Date().toISOString(), - }).run() + if (/активного IP|Server not found/i.test(raw)) { + return reply.status(400).send({ error: message }) } return reply.status(502).send({ error: message }) } @@ -601,6 +476,10 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { const allServers = db.select().from(servers).all() const probeRows = readProbeRows() const probeSamples = readProbeSamplesSince(sinceIso) + const uptimeCfg = readUptimeSettings() + const intervalSec = Math.max(15, uptimeCfg.intervalSec ?? 300) + /** Допустимый разрыв между последней попыткой и последним «хорошим» сэмплом (не показывать данные часовой давности). */ + const resourceFallbackMaxGapMs = Math.min(2 * 3600 * 1000, Math.max(10 * 60 * 1000, intervalSec * 6 * 1000)) const probes = probeRows.map((p) => { const rows = probeSamples.filter((r) => r.probeId === p.id) @@ -624,18 +503,19 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { const resources = allServers.map((s) => { const rows = readResourceSamplesSince(sinceIso, s.id) - const last = rows[rows.length - 1] + const { row: pick, hasData } = pickResourceDisplayRow(rows, resourceFallbackMaxGapMs) const cpuHistory = toSeries(rows.map((r) => r.cpuLoad), 40) return { serverId: String(s.id), - cpu: last?.cpuLoad ?? 0, + hasData, + cpu: pick?.cpuLoad ?? 0, cpuHistory, - ramUsed: Math.max(0, ((last?.totalMemory ?? 0) - (last?.freeMemory ?? 0)) / (1024 * 1024)), - ramTotal: Math.max(0, (last?.totalMemory ?? 0) / (1024 * 1024)), - hddUsed: Math.max(0, ((last?.totalHddSpace ?? 0) - (last?.freeHddSpace ?? 0)) / (1024 * 1024)), - hddTotal: Math.max(0, (last?.totalHddSpace ?? 0) / (1024 * 1024)), - uptimeSeconds: last?.uptimeSeconds ?? 0, - boardName: last?.boardName || "RouterBOARD", + ramUsed: Math.max(0, ((pick?.totalMemory ?? 0) - (pick?.freeMemory ?? 0)) / (1024 * 1024)), + ramTotal: Math.max(0, (pick?.totalMemory ?? 0) / (1024 * 1024)), + hddUsed: Math.max(0, ((pick?.totalHddSpace ?? 0) - (pick?.freeHddSpace ?? 0)) / (1024 * 1024)), + hddTotal: Math.max(0, (pick?.totalHddSpace ?? 0) / (1024 * 1024)), + uptimeSeconds: pick?.uptimeSeconds ?? 0, + boardName: pick?.boardName || "RouterBOARD", temp: undefined as number | undefined, } }) diff --git a/backend/src/services/alert-collector-hooks.ts b/backend/src/services/alert-collector-hooks.ts new file mode 100644 index 0000000..051b018 --- /dev/null +++ b/backend/src/services/alert-collector-hooks.ts @@ -0,0 +1,48 @@ +/** + * Внутренние хуки: после записи сигналов в SQLite (коллекторы) — отложенный прогон `alert_engine`. + * Не импортирует `scheduler.ts` (избегаем цикла); runner задаётся через `wireAlertEngineRunner`. + * + * Любой код, пишущий таблицы для `buildSignalSnapshot`, после успешного коммита должен вызывать + * `scheduleAlertEngineAfterDataCollectors()`. + */ +import { isSchedulerJobRunning } from "./scheduler-running.js" + +const ALERT_AFTER_COLLECT_DEBOUNCE_MS = 250 +const ALERT_AFTER_COLLECT_WAIT_MS = 100 + +let alertAfterCollectTimer: ReturnType | null = null +let alertAfterCollectChain: Promise = Promise.resolve() + +let alertEngineRunner: (() => Promise) | null = null + +/** Задать runner один раз при старте приложения (обычно `() => executeSchedulerJob("alert_engine")`). */ +export function wireAlertEngineRunner(run: () => Promise): void { + alertEngineRunner = run +} + +export function clearAlertCollectorHooksTimer(): void { + if (alertAfterCollectTimer != null) { + clearTimeout(alertAfterCollectTimer) + alertAfterCollectTimer = null + } +} + +async function runAlertEngineWhenIdle(): Promise { + const run = alertEngineRunner + if (!run) return + while (isSchedulerJobRunning("alert_engine")) { + await new Promise((resolve) => setTimeout(resolve, ALERT_AFTER_COLLECT_WAIT_MS)) + } + await run() +} + +/** После успешной записи сигналов в БД коллекторами (не из `alert_engine`). */ +export function scheduleAlertEngineAfterDataCollectors(): void { + if (alertAfterCollectTimer != null) clearTimeout(alertAfterCollectTimer) + alertAfterCollectTimer = setTimeout(() => { + alertAfterCollectTimer = null + alertAfterCollectChain = alertAfterCollectChain + .then(() => runAlertEngineWhenIdle()) + .catch(() => {}) + }, ALERT_AFTER_COLLECT_DEBOUNCE_MS) +} diff --git a/backend/src/services/alert-engine/confirm-stability.test.ts b/backend/src/services/alert-engine/confirm-stability.test.ts new file mode 100644 index 0000000..17ff75a --- /dev/null +++ b/backend/src/services/alert-engine/confirm-stability.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict" +import { eq } from "drizzle-orm" +import { db } from "../../db/index.js" +import { alertEngineConfirmPending } from "../../db/schema.js" +import type { ApiAlertRule } from "../alerts-service.js" +import { computeStabilityReadyMap, deleteConfirmPending } from "./confirm-stability.js" + +const ruleId = "test-confirm-stability-recovery-bypass" + +deleteConfirmPending(ruleId) + +const baseRule: ApiAlertRule = { + id: ruleId, + name: "Server transition", + type: "server", + target: "core-1", + targets: ["core-1"], + groupId: null, + condition: "перешёл в offline · восстановился", + conditions: ["перешёл в offline", "восстановился"], + severity: "critical", + enabled: true, + cooldown: "5м", + confirmStabilitySec: 120, + recoveryMode: "always", + recoveryStabilitySec: null, + chatId: "", + lastFiredAt: null, +} + +const problemHit = { + ruleId, + ruleName: baseRule.name, + severity: baseRule.severity, + message: "Сервер: core-1 — перешёл в offline", + transition: "problem" as const, + payloadHash: "problem-1", +} + +const recoveryHit = { + ruleId, + ruleName: baseRule.name, + severity: baseRule.severity, + message: "Сервер: core-1 — восстановился", + transition: "recovery" as const, + payloadHash: "recovery-1", +} + +{ + const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, problemHit]])) + assert.equal(m.get(ruleId), false, "проблемный переход должен ждать confirmStabilitySec") +} + +{ + const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, recoveryHit]])) + assert.equal(m.get(ruleId), true, "восстановление должно отправляться без задержки") +} + +{ + const m = computeStabilityReadyMap([baseRule], new Map([[ruleId, null]])) + assert.equal(m.get(ruleId), false, "без hit отправка не готова") + const left = db + .select() + .from(alertEngineConfirmPending) + .where(eq(alertEngineConfirmPending.ruleId, ruleId)) + .limit(1) + .all()[0] + assert.equal(left, undefined, "pending должен очищаться после отсутствия hit") +} + +console.log("alert-engine confirm-stability tests ok") diff --git a/backend/src/services/alert-engine/confirm-stability.ts b/backend/src/services/alert-engine/confirm-stability.ts new file mode 100644 index 0000000..a0ba6a7 --- /dev/null +++ b/backend/src/services/alert-engine/confirm-stability.ts @@ -0,0 +1,82 @@ +import { eq } from "drizzle-orm" +import { db } from "../../db/index.js" +import { alertEngineConfirmPending } from "../../db/schema.js" +import type { ApiAlertRule } from "../alerts-service.js" +import type { RuleEvalHit } from "./types.js" + +export function deleteConfirmPending(ruleId: string) { + db.delete(alertEngineConfirmPending).where(eq(alertEngineConfirmPending.ruleId, ruleId)).run() +} + +/** + * Обновляет состояние ожидания стабильности и возвращает, можно ли отправлять уведомление в этом тике. + * При отсутствии срабатывания (`hit == null`) ожидание сбрасывается. + */ +export function stabilityReadyForSend( + ruleId: string, + hit: RuleEvalHit | null, + delaySec: number | null | undefined, +): boolean { + if (!hit) { + deleteConfirmPending(ruleId) + return false + } + const sec = delaySec != null && delaySec > 0 ? Math.floor(delaySec) : 0 + if (sec <= 0) { + deleteConfirmPending(ruleId) + return true + } + + const now = Date.now() + const nowIso = new Date().toISOString() + const existing = db + .select() + .from(alertEngineConfirmPending) + .where(eq(alertEngineConfirmPending.ruleId, ruleId)) + .limit(1) + .all()[0] + + if (!existing || existing.payloadHash !== hit.payloadHash) { + if (existing) { + db.update(alertEngineConfirmPending) + .set({ payloadHash: hit.payloadHash, sinceAt: nowIso }) + .where(eq(alertEngineConfirmPending.ruleId, ruleId)) + .run() + } else { + db.insert(alertEngineConfirmPending).values({ ruleId, payloadHash: hit.payloadHash, sinceAt: nowIso }).run() + } + return false + } + + const t = Date.parse(existing.sinceAt) + if (!Number.isFinite(t)) return false + return now - t >= sec * 1000 +} + +/** После успешной отправки — сбросить таймер стабильности для следующего цикла. */ +export function clearConfirmPendingAfterSend(ruleIds: string[]) { + for (const id of ruleIds) deleteConfirmPending(id) +} + +export function computeStabilityReadyMap( + rules: ApiAlertRule[], + hitByRule: Map, +): Map { + const m = new Map() + for (const rule of rules) { + if (!rule.enabled) { + deleteConfirmPending(rule.id) + m.set(rule.id, false) + continue + } + const hit = hitByRule.get(rule.id) ?? null + const delaySec = + hit?.transition === "recovery" + ? rule.recoveryMode === "conditional" + ? rule.recoveryStabilitySec + : 0 + : rule.confirmStabilitySec + m.set(rule.id, stabilityReadyForSend(rule.id, hit, delaySec)) + } + return m +} diff --git a/backend/src/services/alert-engine/cooldown.ts b/backend/src/services/alert-engine/cooldown.ts new file mode 100644 index 0000000..5047f72 --- /dev/null +++ b/backend/src/services/alert-engine/cooldown.ts @@ -0,0 +1,22 @@ +import type { ApiAlertRule } from "../alerts-service.js" + +const COOLDOWN_MS: Record = { + "1м": 60_000, + "5м": 5 * 60_000, + "15м": 15 * 60_000, + "1ч": 60 * 60_000, + "4ч": 4 * 60 * 60_000, + "24ч": 24 * 60 * 60_000, +} + +export function cooldownToMs(cd: ApiAlertRule["cooldown"]): number { + return COOLDOWN_MS[cd] ?? COOLDOWN_MS["5м"] +} + +/** Можно ли отправить снова после lastIso (ISO) */ +export function isCooldownElapsed(lastFiredIso: string | null | undefined, cooldown: ApiAlertRule["cooldown"]): boolean { + if (!lastFiredIso?.trim()) return true + const t = Date.parse(lastFiredIso) + if (!Number.isFinite(t)) return true + return Date.now() - t >= cooldownToMs(cooldown) +} diff --git a/backend/src/services/alert-engine/decision-engine.ts b/backend/src/services/alert-engine/decision-engine.ts new file mode 100644 index 0000000..3c7b3dc --- /dev/null +++ b/backend/src/services/alert-engine/decision-engine.ts @@ -0,0 +1,136 @@ +import type { ApiAlertGroup, ApiAlertRule } from "../alerts-service.js" +import { computeStabilityReadyMap } from "./confirm-stability.js" +import { isCooldownElapsed } from "./cooldown.js" +import { groupShouldFire } from "./groups.js" +import { isPositiveRecoveryTelegramText } from "./telegram-emoji.js" +import { shouldAllowRecoveryByPolicy } from "./state-machine.js" +import type { AlertEngineRuleDiag, RuleEvalHit } from "./types.js" + +export type EngineStateMap = Map + +export type StandaloneDecision = { + kind: "rule" + rule: ApiAlertRule + hit: RuleEvalHit +} + +export type GroupDecision = { + kind: "group" + group: ApiAlertGroup + members: ApiAlertRule[] + hits: RuleEvalHit[] +} + +function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): string { + const suffix = transition ?? "neutral" + return `rule:${ruleId}:${suffix}` +} + +function textAfterGroupAlertHeader(fullBody: string): string { + const segs = fullBody.split(/\n\n/) + if (segs.length >= 2 && /^Группа\s*«/i.test(segs[0] ?? "")) return segs.slice(1).join("\n\n").trim() + return fullBody +} + +export function buildRuleDiagnostics( + rules: ApiAlertRule[], + hitByRule: Map, + stabilityReady: Map, + state: EngineStateMap, + canSend: boolean, +): AlertEngineRuleDiag[] { + const out: AlertEngineRuleDiag[] = [] + for (const rule of rules) { + if (!rule.enabled) continue + const hit = hitByRule.get(rule.id) ?? null + const inGroup = Boolean(rule.groupId) + const evalHit = Boolean(hit) + const stab = stabilityReady.get(rule.id) ?? false + const key = ruleScopeKey(rule.id, hit?.transition) + const prev = state.get(key) + const cooldownOk = isCooldownElapsed(prev?.lastFiredAt, rule.cooldown) + let blocked: AlertEngineRuleDiag["blocked"] + if (!evalHit) blocked = "no_hit" + else if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) blocked = "stability" + else if (inGroup) blocked = "in_group" + else if (!stab) blocked = "stability" + else if (!cooldownOk) blocked = "cooldown" + else if (!canSend) blocked = "no_telegram" + else if (hit && isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) blocked = "dedupe_positive" + else blocked = undefined + out.push({ + ruleId: rule.id, + inGroup, + evalHit, + hitTransition: hit?.transition, + hitMessage: hit?.message, + stabilityOk: stab, + cooldownOk, + telegramOk: canSend, + blocked, + }) + } + return out +} + +export function computeDecisions(args: { + rules: ApiAlertRule[] + groups: ApiAlertGroup[] + hitByRule: Map + state: EngineStateMap + canSend: boolean +}): { + stabilityReady: Map + standalone: StandaloneDecision[] + grouped: GroupDecision[] + ruleDiag: AlertEngineRuleDiag[] +} { + const { rules, groups, hitByRule, state, canSend } = args + const stabilityReady = computeStabilityReadyMap(rules, hitByRule) + const ruleDiag = buildRuleDiagnostics(rules, hitByRule, stabilityReady, state, canSend) + const standalone: StandaloneDecision[] = [] + const grouped: GroupDecision[] = [] + + for (const rule of rules) { + if (!rule.enabled || rule.groupId) continue + const hit = hitByRule.get(rule.id) + if (!hit) continue + if (!shouldAllowRecoveryByPolicy(hit, rule.recoveryMode)) continue + if (!stabilityReady.get(rule.id)) continue + const key = ruleScopeKey(rule.id, hit.transition) + const prev = state.get(key) + if (!isCooldownElapsed(prev?.lastFiredAt, rule.cooldown)) continue + if (isPositiveRecoveryTelegramText(hit.message) && prev?.lastPayloadHash === hit.payloadHash) continue + standalone.push({ kind: "rule", rule, hit }) + } + + for (const g of groups) { + if (!g.enabled) continue + const members = rules.filter((r) => r.groupId === g.id && r.enabled) + if (members.length === 0) continue + const firing = members.map((r) => { + const h = hitByRule.get(r.id) + if (!shouldAllowRecoveryByPolicy(h ?? null, r.recoveryMode)) return false + return Boolean(h) && Boolean(stabilityReady.get(r.id)) + }) + if (!groupShouldFire(g.combineMode, firing)) continue + const hits: RuleEvalHit[] = [] + for (const member of members) { + const hit = hitByRule.get(member.id) + if (!hit) continue + if (!shouldAllowRecoveryByPolicy(hit, member.recoveryMode)) continue + hits.push(hit) + } + if (hits.length === 0) continue + const key = `group:${g.id}` + const prev = state.get(key) + const cd = g.cooldownOverride ?? "5м" + if (!isCooldownElapsed(prev?.lastFiredAt, cd)) continue + const body = `Группа «${g.name}» (${g.combineMode === "any" ? "ANY" : "ALL"})\n\n${hits.map((h) => h.message).join("\n")}` + const hash = hits.map((h) => h.payloadHash).sort().join("|") + if (isPositiveRecoveryTelegramText(textAfterGroupAlertHeader(body)) && prev?.lastPayloadHash === hash) continue + grouped.push({ kind: "group", group: g, members, hits }) + } + + return { stabilityReady, standalone, grouped, ruleDiag } +} diff --git a/backend/src/services/alert-engine/evaluate-rule.test.ts b/backend/src/services/alert-engine/evaluate-rule.test.ts new file mode 100644 index 0000000..7815c07 --- /dev/null +++ b/backend/src/services/alert-engine/evaluate-rule.test.ts @@ -0,0 +1,227 @@ +import assert from "node:assert/strict" +import type { ApiAlertRule } from "../alerts-service.js" +import { evaluateRule } from "./evaluate-rule.js" +import type { SignalSnapshot } from "./types.js" + +function emptySnap(over: Partial = {}): SignalSnapshot { + return { + sampledAt: "t", + servers: [], + probes: [], + trafficByServer: [], + greTunnels: [], + bgpSessions: [], + ...over, + } +} + +function rule(p: Partial & Pick): ApiAlertRule { + return { + target: p.targets[0] ?? "", + groupId: null, + condition: p.conditions?.[0] ?? p.condition ?? "", + conditions: p.conditions ?? (p.condition ? [p.condition] : []), + severity: p.severity ?? "warning", + enabled: p.enabled ?? true, + cooldown: p.cooldown ?? "5м", + confirmStabilitySec: null, + recoveryMode: "always", + recoveryStabilitySec: null, + chatId: "", + lastFiredAt: null, + ...p, + } as ApiAlertRule +} + +{ + const r = rule({ + id: "g1", + name: "GRE", + type: "gre-tunnel", + targets: ["t1 / srv"], + conditions: ["перешёл в offline"], + }) + const hit = evaluateRule( + r, + emptySnap({ + greTunnels: [{ targetLabel: "t1 / srv", status: "down", prevStatus: "up" }], + }), + ) + assert.ok(hit) +} + +{ + const r = rule({ + id: "g2", + name: "GRE rec", + type: "gre-tunnel", + targets: ["t1 / srv"], + conditions: ["восстановился"], + }) + const hit = evaluateRule( + r, + emptySnap({ + greTunnels: [{ targetLabel: "t1 / srv", status: "up", prevStatus: "down" }], + }), + ) + assert.ok(hit) +} + +{ + const r = rule({ + id: "b1", + name: "BGP", + type: "bgp-peer", + targets: ["srv: peer1"], + conditions: ["разорвал сессию"], + }) + const hit = evaluateRule( + r, + emptySnap({ + bgpSessions: [{ key: "srv: peer1", state: "Idle", prevState: "Established" }], + }), + ) + assert.ok(hit) +} + +{ + const r = rule({ + id: "b2", + name: "BGP up", + type: "bgp-peer", + targets: ["srv: peer1"], + conditions: ["восстановил сессию"], + }) + const hit = evaluateRule( + r, + emptySnap({ + bgpSessions: [{ key: "srv: peer1", state: "Established", prevState: "Active" }], + }), + ) + assert.ok(hit) +} + +{ + const r = rule({ + id: "pfx", + name: "pfx", + type: "bgp-prefix", + targets: ["любой префикс"], + conditions: ["был отозван"], + }) + assert.equal(evaluateRule(r, emptySnap()), null) +} + +{ + const r = rule({ + id: "srv-rec", + name: "rec", + type: "server", + targets: ["core"], + conditions: ["восстановился"], + }) + assert.ok( + evaluateRule( + r, + emptySnap({ + servers: [ + { + name: "core", + status: "online", + prevStatus: "offline", + prev2Status: "offline", + sampledAt: "t1", + }, + ], + }), + ), + "один online после offline — тоже «восстановился» (ручной REST / ресурс уже online)", + ) + const hit = evaluateRule( + r, + emptySnap({ + servers: [ + { + name: "core", + status: "online", + prevStatus: "online", + prev2Status: "offline", + sampledAt: "t2", + }, + ], + }), + ) + assert.ok(hit, "два подряд online после offline — одно срабатывание") + assert.equal(hit?.transition, "recovery") +} + +{ + const r = rule({ + id: "srv-rec-offline-phrase", + name: "rec-offline-phrase", + type: "server", + targets: ["core"], + conditions: ["восстановился после offline"], + }) + const hit = evaluateRule( + r, + emptySnap({ + servers: [ + { + name: "core", + status: "online", + prevStatus: "online", + prev2Status: "offline", + sampledAt: "t2", + }, + ], + }), + ) + assert.ok(hit, "в тексте условия есть «offline» — всё равно восстановление по сэмплам") +} + +{ + const r = rule({ + id: "srv-off", + name: "off", + type: "server", + targets: ["core"], + conditions: ["перешёл в offline"], + }) + const edgeHit = evaluateRule( + r, + emptySnap({ + servers: [ + { + name: "core", + status: "offline", + prevStatus: "online", + prev2Status: "online", + sampledAt: "t", + }, + ], + }), + ) + assert.ok(edgeHit) + assert.equal(edgeHit?.transition, "problem") + assert.equal( + evaluateRule( + r, + emptySnap({ + servers: [ + { + name: "core", + status: "offline", + prevStatus: "offline", + prev2Status: "online", + sampledAt: "t", + }, + ], + }), + ), + null, + "устойчивый offline — без повторного «перешёл» на каждом тике", + ) +} + +console.log("alert-engine evaluate-rule tests ok") diff --git a/backend/src/services/alert-engine/evaluate-rule.ts b/backend/src/services/alert-engine/evaluate-rule.ts new file mode 100644 index 0000000..1a484ed --- /dev/null +++ b/backend/src/services/alert-engine/evaluate-rule.ts @@ -0,0 +1,323 @@ +import { createHash } from "node:crypto" +import type { ApiAlertRule } from "../alerts-service.js" +import type { RuleEvalHit, SignalSnapshot } from "./types.js" + +function hashPayload(parts: string[]): string { + return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 24) +} + +function firstNumberInString(s: string): number | null { + const m = s.replace(",", ".").match(/(\d+(?:\.\d+)?)/) + if (!m) return null + const n = Number.parseFloat(m[1] ?? "") + return Number.isFinite(n) ? n : null +} + +function maxSeverity( + a: ApiAlertRule["severity"], + b: ApiAlertRule["severity"], +): ApiAlertRule["severity"] { + const o = { critical: 3, warning: 2, info: 1 } as const + return o[a] >= o[b] ? a : b +} + +function findServer(snap: SignalSnapshot, name: string) { + const n = name.trim().toLowerCase() + return snap.servers.find((s) => s.name.trim().toLowerCase() === n) +} + +function findProbe(snap: SignalSnapshot, targetKey: string) { + const k = targetKey.trim() + return snap.probes.find((p) => p.key.trim() === k) +} + +function findTraffic(snap: SignalSnapshot, serverName: string) { + const n = serverName.trim().toLowerCase() + return snap.trafficByServer.find((t) => t.serverName.trim().toLowerCase() === n) +} + +function findGreTunnel(snap: SignalSnapshot, targetLabel: string) { + const n = targetLabel.trim() + return snap.greTunnels.find((g) => g.targetLabel.trim() === n) +} + +function findBgpPeer(snap: SignalSnapshot, key: string) { + const n = key.trim() + return snap.bgpSessions.find((b) => b.key.trim() === n) +} + +function isBgpEstablished(state: string): boolean { + return state.trim().toLowerCase() === "established" +} + +/** Заголовок для Telegram: не «…недоступен: … — восстановился». */ +function formatServerAlertTitle(ruleName: string, wantRecovery: boolean): string { + if (!wantRecovery) return ruleName + if (!/недоступен/i.test(ruleName)) return ruleName + const trimmed = ruleName.replace(/\s*,\s*недоступен\s*$/i, "").replace(/\s+недоступен\s*$/i, "").trim() + if (!trimmed || trimmed === ruleName) return ruleName + return `${trimmed} — снова в сети` +} + +function evalServerLike( + rule: ApiAlertRule, + snap: SignalSnapshot, + cond: string, +): RuleEvalHit | null { + const c = cond.toLowerCase() + const isOtkluchilsya = /отключился|отключилась|отключилось/.test(c) + /** «Перешёл в …» — только на границе, без спама пока сэмплы остаются offline. */ + const impliesOfflineEdge = /переш(ё|е)л/.test(c) && (/offline|офлайн/.test(c) || /недоступен/.test(c)) + const impliesDegradedEdge = /переш(ё|е)л/.test(c) && c.includes("degraded") + const wantOffline = + c.includes("offline") || c.includes("офлайн") || c.includes("отключ") + const wantDegraded = c.includes("degraded") + /** «Восстановился» / «подключился»: двойной online в сэмплах (см. тесты). Не отсекаем по слову «offline» в фразе вроде «после offline». */ + const wantRecovery = + (c.includes("восстанов") || c.includes("подключ")) && !isOtkluchilsya + const hits: string[] = [] + for (const tgt of rule.targets) { + const s = findServer(snap, tgt) + if (!s) continue + if (wantRecovery) { + const p2 = s.prev2Status + if ( + s.status === "online" && + s.prevStatus === "online" && + p2 != null && + (p2 === "offline" || p2 === "degraded") + ) { + hits.push(`${tgt}`) + } else if ( + s.status === "online" && + (s.prevStatus === "offline" || s.prevStatus === "degraded") + ) { + /** Один свежий online после offline (например ресурс уже online, REST только подтвердил). */ + hits.push(`${tgt}`) + } + } else if ((impliesOfflineEdge || isOtkluchilsya) && s.status === "offline") { + if (s.prevStatus === null || s.prevStatus === "online") hits.push(tgt) + } else if (impliesDegradedEdge && s.status === "degraded") { + if (s.prevStatus === null || s.prevStatus === "online") hits.push(tgt) + } else if (wantDegraded && s.status === "degraded" && !impliesDegradedEdge) { + hits.push(tgt) + } else if ( + wantOffline && + s.status === "offline" && + !impliesOfflineEdge && + !isOtkluchilsya + ) { + hits.push(tgt) + } + } + if (hits.length === 0) return null + const title = formatServerAlertTitle(rule.name, wantRecovery) + const msg = `${title}: ${hits.join(", ")} — ${cond}` + return { + ruleId: rule.id, + ruleName: rule.name, + severity: rule.severity, + message: msg, + transition: wantRecovery ? "recovery" : "problem", + payloadHash: hashPayload([rule.id, "server", hits.sort().join(","), cond]), + } +} + +function evalRtt(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null { + const threshold = firstNumberInString(cond) + const wantLow = /<|ниже|меньше/i.test(cond) && !/>|выше|больше/i.test(cond) + const wantHigh = />|выше|больше/i.test(cond) + const hits: string[] = [] + for (const tgt of rule.targets) { + const p = findProbe(snap, tgt) + if (!p) continue + if (p.status === "down" || p.status === "warn") { + hits.push(`${tgt} (${p.status}, RTT ${p.rttMs ?? "—"} мс)`) + continue + } + if (p.rttMs == null) continue + if (wantHigh && threshold != null && p.rttMs > threshold) hits.push(`${tgt} (${p.rttMs} мс)`) + if (wantLow && threshold != null && p.rttMs < threshold) hits.push(`${tgt} (${p.rttMs} мс)`) + } + if (hits.length === 0) return null + const msg = `${rule.name}: RTT ${cond} — ${hits.join("; ")}` + return { + ruleId: rule.id, + ruleName: rule.name, + severity: rule.severity, + message: msg, + transition: "neutral", + payloadHash: hashPayload([rule.id, "rtt", hits.sort().join(";"), cond]), + } +} + +function evalLoss(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null { + const threshold = firstNumberInString(cond) + if (threshold == null) return null + const hits: string[] = [] + for (const tgt of rule.targets) { + const p = findProbe(snap, tgt) + if (!p || p.lossPct == null) continue + if (p.lossPct > threshold) hits.push(`${tgt} (${p.lossPct}%)`) + } + if (hits.length === 0) return null + const msg = `${rule.name}: потери ${cond} — ${hits.join("; ")}` + return { + ruleId: rule.id, + ruleName: rule.name, + severity: rule.severity, + message: msg, + transition: "neutral", + payloadHash: hashPayload([rule.id, "loss", hits.sort().join(";"), cond]), + } +} + +function evalGreTunnel(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null { + const lc = cond.toLowerCase() + const wantOffline = lc.includes("offline") && !lc.includes("восстанов") + const wantRecovery = + lc.includes("восстанов") && !/offline|отключ/i.test(lc) + const hits: string[] = [] + for (const tgt of rule.targets) { + const g = findGreTunnel(snap, tgt) + if (!g) continue + if (wantRecovery) { + const prev = g.prevStatus + if ( + g.status === "up" && + prev != null && + (prev === "down" || prev === "degraded") + ) { + hits.push(tgt) + } + } else if (wantOffline && g.status === "down") { + hits.push(tgt) + } + } + if (hits.length === 0) return null + const msg = `${rule.name}: ${hits.join(", ")} — ${cond}` + return { + ruleId: rule.id, + ruleName: rule.name, + severity: rule.severity, + message: msg, + transition: wantRecovery ? "recovery" : "problem", + payloadHash: hashPayload([rule.id, "gre-tunnel", hits.sort().join(","), cond]), + } +} + +function evalBgpPeer(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null { + const lc = cond.toLowerCase() + const wantDown = lc.includes("разорвал") + const wantUp = lc.includes("восстановил") + const hits: string[] = [] + for (const tgt of rule.targets) { + const b = findBgpPeer(snap, tgt) + if (!b) continue + if (wantDown && !isBgpEstablished(b.state)) hits.push(tgt) + if ( + wantUp && + isBgpEstablished(b.state) && + b.prevState != null && + String(b.prevState).trim() !== "" && + !isBgpEstablished(b.prevState) + ) { + hits.push(tgt) + } + } + if (hits.length === 0) return null + const msg = `${rule.name}: ${hits.join(", ")} — ${cond}` + return { + ruleId: rule.id, + ruleName: rule.name, + severity: rule.severity, + message: msg, + transition: wantUp ? "recovery" : "problem", + payloadHash: hashPayload([rule.id, "bgp-peer", hits.sort().join(","), cond]), + } +} + +function evalTraffic(rule: ApiAlertRule, snap: SignalSnapshot, cond: string): RuleEvalHit | null { + const threshold = firstNumberInString(cond) + if (threshold == null) return null + const rxLow = /RX\s* 0 + ? rule.conditions.map((c) => c.trim()).filter(Boolean) + : [rule.condition.trim()].filter(Boolean) + if (conds.length === 0) return null + + const hits: RuleEvalHit[] = [] + for (const cond of conds) { + const h = evaluateRuleWithCondition(rule, snap, cond) + if (h) hits.push(h) + } + if (hits.length === 0) return null + if (hits.length === 1) return hits[0]! + const message = hits.map((h) => h.message).join("\n") + const severity = hits.reduce((a, h) => maxSeverity(a, h.severity), hits[0]!.severity) + const payloadHash = hashPayload([rule.id, "or-conds", ...hits.map((h) => h.payloadHash).sort()]) + return { + ruleId: rule.id, + ruleName: rule.name, + severity, + message, + transition: hits.some((h) => h.transition === "problem") + ? "problem" + : hits.some((h) => h.transition === "recovery") + ? "recovery" + : "neutral", + payloadHash, + } +} diff --git a/backend/src/services/alert-engine/groups.test.ts b/backend/src/services/alert-engine/groups.test.ts new file mode 100644 index 0000000..46b7ee2 --- /dev/null +++ b/backend/src/services/alert-engine/groups.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict" +import { groupShouldFire } from "./groups.js" +import { cooldownToMs, isCooldownElapsed } from "./cooldown.js" + +assert.equal(groupShouldFire("any", []), false) +assert.equal(groupShouldFire("any", [false, false]), false) +assert.equal(groupShouldFire("any", [false, true]), true) +assert.equal(groupShouldFire("all", [true, true]), true) +assert.equal(groupShouldFire("all", [true, false]), false) +assert.equal(groupShouldFire("all", [false, false]), false) + +assert.equal(cooldownToMs("1м"), 60_000) +assert.equal(isCooldownElapsed(null, "5м"), true) +assert.equal(isCooldownElapsed(new Date(Date.now() - 6 * 60_000).toISOString(), "5м"), true) +assert.equal(isCooldownElapsed(new Date(Date.now() - 2 * 60_000).toISOString(), "5м"), false) + +console.log("alert-engine groups/cooldown tests ok") diff --git a/backend/src/services/alert-engine/groups.ts b/backend/src/services/alert-engine/groups.ts new file mode 100644 index 0000000..9193468 --- /dev/null +++ b/backend/src/services/alert-engine/groups.ts @@ -0,0 +1,9 @@ +/** + * Логика группы: ANY = хотя бы один член «в огне»; ALL = все члены «в огне». + * `memberFiring` — только включённые правила группы, по порядку. + */ +export function groupShouldFire(combineMode: "any" | "all", memberFiring: boolean[]): boolean { + if (memberFiring.length === 0) return false + if (combineMode === "any") return memberFiring.some(Boolean) + return memberFiring.every(Boolean) +} diff --git a/backend/src/services/alert-engine/outbox.ts b/backend/src/services/alert-engine/outbox.ts new file mode 100644 index 0000000..c7439d6 --- /dev/null +++ b/backend/src/services/alert-engine/outbox.ts @@ -0,0 +1,138 @@ +import { and, asc, eq, lte } from "drizzle-orm" +import { db } from "../../db/index.js" +import { alertOutbox } from "../../db/schema.js" +import { appendAlertHistory, sendTelegramAlertMessage } from "../alerts-service.js" + +export type AlertOutboxPayload = { + text: string + chatId?: string + history: { + id: string + ruleId?: string | null + groupId?: string | null + ruleName: string + severity: "critical" | "warning" | "info" + message: string + firedAt: string + } +} + +function safeParsePayload(raw: string): AlertOutboxPayload | null { + try { + return JSON.parse(raw) as AlertOutboxPayload + } catch { + return null + } +} + +export function enqueueTelegramOutbox(input: { + id: string + dedupeKey: string + payload: AlertOutboxPayload + maxRetries?: number +}): boolean { + const existing = db + .select() + .from(alertOutbox) + .where(eq(alertOutbox.dedupeKey, input.dedupeKey)) + .limit(1) + .all()[0] + if (existing && (existing.status === "pending" || existing.status === "sent")) return false + const nowIso = new Date().toISOString() + db.insert(alertOutbox) + .values({ + id: input.id, + dedupeKey: input.dedupeKey, + channel: "telegram", + status: "pending", + retryCount: 0, + maxRetries: Math.max(1, Math.floor(input.maxRetries ?? 3)), + nextAttemptAt: nowIso, + payloadJson: JSON.stringify(input.payload), + }) + .run() + return true +} + +export function dispatchPendingOutbox(limit = 25): Promise<{ sent: number; failed: number; errors: string[] }> { + return (async () => { + const nowIso = new Date().toISOString() + const rows = db + .select() + .from(alertOutbox) + .where(and(eq(alertOutbox.status, "pending"), lte(alertOutbox.nextAttemptAt, nowIso))) + .orderBy(asc(alertOutbox.createdAt)) + .limit(Math.max(1, limit)) + .all() + + let sent = 0 + let failed = 0 + const errors: string[] = [] + + for (const row of rows) { + const payload = safeParsePayload(row.payloadJson) + if (!payload) { + db.update(alertOutbox) + .set({ + status: "failed", + lastError: "invalid payload_json", + }) + .where(eq(alertOutbox.id, row.id)) + .run() + failed += 1 + continue + } + const send = await sendTelegramAlertMessage({ text: payload.text, chatId: payload.chatId }) + if (send.ok) { + db.update(alertOutbox) + .set({ + status: "sent", + sentAt: new Date().toISOString(), + lastError: null, + }) + .where(eq(alertOutbox.id, row.id)) + .run() + appendAlertHistory({ + id: payload.history.id, + ruleId: payload.history.ruleId ?? null, + groupId: payload.history.groupId ?? null, + ruleName: payload.history.ruleName, + severity: payload.history.severity, + message: payload.history.message, + sentOk: true, + firedAt: payload.history.firedAt, + }) + sent += 1 + continue + } + const nextRetry = row.retryCount + 1 + const exhausted = nextRetry >= row.maxRetries + db.update(alertOutbox) + .set({ + retryCount: nextRetry, + status: exhausted ? "failed" : "pending", + nextAttemptAt: new Date(Date.now() + Math.min(300_000, nextRetry * 15_000)).toISOString(), + lastError: send.error, + }) + .where(eq(alertOutbox.id, row.id)) + .run() + if (exhausted) { + appendAlertHistory({ + id: payload.history.id, + ruleId: payload.history.ruleId ?? null, + groupId: payload.history.groupId ?? null, + ruleName: payload.history.ruleName, + severity: payload.history.severity, + message: payload.history.message, + sentOk: false, + firedAt: payload.history.firedAt, + }) + failed += 1 + } else { + errors.push(`outbox ${row.id}: ${send.error}`) + } + } + + return { sent, failed, errors } + })() +} diff --git a/backend/src/services/alert-engine/prev-live-store.ts b/backend/src/services/alert-engine/prev-live-store.ts new file mode 100644 index 0000000..6f32049 --- /dev/null +++ b/backend/src/services/alert-engine/prev-live-store.ts @@ -0,0 +1,47 @@ +import { eq } from "drizzle-orm" +import { db } from "../../db/index.js" +import { alertEnginePrevLive } from "../../db/schema.js" + +export type PrevLiveKind = "gre" | "bgp" + +/** Предыдущие строковые состояния (GRE: up|down|degraded; BGP: state как с роутера). */ +export type PrevLiveStringMap = Record + +function safeParseMap(json: string | null | undefined): PrevLiveStringMap { + if (!json || json.trim() === "") return {} + try { + const v = JSON.parse(json) as unknown + if (v == null || typeof v !== "object" || Array.isArray(v)) return {} + return v as PrevLiveStringMap + } catch { + return {} + } +} + +export function loadPrevLiveMap(kind: PrevLiveKind): PrevLiveStringMap { + const row = db + .select() + .from(alertEnginePrevLive) + .where(eq(alertEnginePrevLive.kind, kind)) + .limit(1) + .all()[0] + return safeParseMap(row?.payloadJson) +} + +export function savePrevLiveMap(kind: PrevLiveKind, map: PrevLiveStringMap) { + const payloadJson = JSON.stringify(map) + const existing = db + .select() + .from(alertEnginePrevLive) + .where(eq(alertEnginePrevLive.kind, kind)) + .limit(1) + .all()[0] + if (existing) { + db.update(alertEnginePrevLive) + .set({ payloadJson, updatedAt: new Date().toISOString() }) + .where(eq(alertEnginePrevLive.kind, kind)) + .run() + } else { + db.insert(alertEnginePrevLive).values({ kind, payloadJson, updatedAt: new Date().toISOString() }).run() + } +} diff --git a/backend/src/services/alert-engine/rule-matcher.ts b/backend/src/services/alert-engine/rule-matcher.ts new file mode 100644 index 0000000..600f1c5 --- /dev/null +++ b/backend/src/services/alert-engine/rule-matcher.ts @@ -0,0 +1,20 @@ +import type { ApiAlertRule } from "../alerts-service.js" +import { evaluateRule } from "./evaluate-rule.js" +import type { RuleEvalHit, SignalSnapshot } from "./types.js" + +export function matchRules(rules: ApiAlertRule[], snap: SignalSnapshot): { + hitByRule: Map + errors: string[] +} { + const errors: string[] = [] + const hitByRule = new Map() + for (const rule of rules) { + try { + hitByRule.set(rule.id, evaluateRule(rule, snap)) + } catch (e) { + errors.push(`rule ${rule.id}: ${e instanceof Error ? e.message : String(e)}`) + hitByRule.set(rule.id, null) + } + } + return { hitByRule, errors } +} diff --git a/backend/src/services/alert-engine/run-once.ts b/backend/src/services/alert-engine/run-once.ts new file mode 100644 index 0000000..91f3e6c --- /dev/null +++ b/backend/src/services/alert-engine/run-once.ts @@ -0,0 +1,194 @@ +import { db } from "../../db/index.js" +import { alertEngineState } from "../../db/schema.js" +import { eq } from "drizzle-orm" +import { + getTelegramPublic, + listAlertGroups, + listAlertRules, + type ApiAlertRule, +} from "../alerts-service.js" +import { getGreBgpSnapshotCollectorState } from "../gre-bgp-snapshot-collector.js" +import { isAnyAlertSnapshotSourceJobRunning } from "../scheduler-running.js" +import { getServersRestPingCollectorState } from "../servers-rest-ping-collector.js" +import { isTrafficCollecting } from "../traffic-collector.js" +import { getUptimeCollectorsState } from "../uptime-collector.js" +import { computeDecisions } from "./decision-engine.js" +import { clearConfirmPendingAfterSend } from "./confirm-stability.js" +import { dispatchPendingOutbox, enqueueTelegramOutbox } from "./outbox.js" +import { matchRules } from "./rule-matcher.js" +import { ingestAlertSignals } from "./signal-ingestor.js" +import { getLatestSourceFinishedAt, getSourceWatermark, updateSourceWatermark } from "./source-watermark.js" +import { pickTelegramAlertEmoji } from "./telegram-emoji.js" +import type { AlertEngineRunResult, RuleEvalHit } from "./types.js" + +const SNAPSHOT_SOURCE_WAIT_MS = 30_000 +const SNAPSHOT_SOURCE_POLL_MS = 20 + +/** + * Не строить снимок, пока коллекторы пишут в SQLite — иначе гонка с `alert_engine` по таймеру + * (в т.ч. слот планировщика занят до первого `await` в коллекторе, когда внутренний `collecting` ещё false). + */ +async function awaitSnapshotSourcesIdle(): Promise { + const t0 = Date.now() + while (Date.now() - t0 < SNAPSHOT_SOURCE_WAIT_MS) { + const busy = + isAnyAlertSnapshotSourceJobRunning() || + getServersRestPingCollectorState().running || + getGreBgpSnapshotCollectorState().running || + getUptimeCollectorsState().resources || + getUptimeCollectorsState().ping || + isTrafficCollecting() + if (!busy) return + await new Promise((r) => setTimeout(r, SNAPSHOT_SOURCE_POLL_MS)) + } +} + +function newHistoryId(): string { + return `ah-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function newOutboxId(): string { + return `ao-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function loadEngineStateMap(): Map { + const rows = db.select().from(alertEngineState).all() + const m = new Map() + for (const r of rows) { + m.set(r.scopeKey, { lastFiredAt: r.lastFiredAt, lastPayloadHash: r.lastPayloadHash ?? null }) + } + return m +} + +function upsertEngineState(scopeKey: string, lastFiredAt: string, lastPayloadHash: string | null) { + const existing = db.select().from(alertEngineState).where(eq(alertEngineState.scopeKey, scopeKey)).limit(1).all()[0] + if (existing) { + db.update(alertEngineState) + .set({ lastFiredAt, lastPayloadHash }) + .where(eq(alertEngineState.scopeKey, scopeKey)) + .run() + } else { + db.insert(alertEngineState).values({ scopeKey, lastFiredAt, lastPayloadHash }).run() + } +} + +function severityRank(s: ApiAlertRule["severity"]): number { + if (s === "critical") return 3 + if (s === "warning") return 2 + return 1 +} + +function maxSeverity(a: ApiAlertRule["severity"], b: ApiAlertRule["severity"]): ApiAlertRule["severity"] { + return severityRank(a) >= severityRank(b) ? a : b +} +function ruleScopeKey(ruleId: string, transition?: RuleEvalHit["transition"]): string { + return `rule:${ruleId}:${transition ?? "neutral"}` +} + +/** Один проход движка: оценка правил, группы, Telegram, история, состояние cooldown. */ +export async function runAlertEngineOnce(): Promise { + const errors: string[] = [] + const sampledAt = new Date().toISOString() + const latestSourceFinishedAt = getLatestSourceFinishedAt() + const prevSourceFinishedAt = getSourceWatermark() + const hasNewSources = + latestSourceFinishedAt != null && + (prevSourceFinishedAt == null || latestSourceFinishedAt > prevSourceFinishedAt) + + const rules = listAlertRules() + const groups = listAlertGroups() + const state = loadEngineStateMap() + const pub = getTelegramPublic() + const canSend = pub.tokenConfigured && Boolean(pub.chatId?.trim()) + let standaloneFires = 0 + let groupFires = 0 + + let ruleDiag: AlertEngineRunResult["ruleDiag"] = [] + if (hasNewSources) { + await awaitSnapshotSourcesIdle() + const snap = ingestAlertSignals() + const matched = matchRules(rules, snap) + errors.push(...matched.errors) + const { standalone, grouped, ruleDiag: diag } = computeDecisions({ + rules, + groups, + hitByRule: matched.hitByRule, + state, + canSend, + }) + ruleDiag = diag + + for (const d of standalone) { + if (!canSend) break + const firedAt = new Date().toISOString() + const key = ruleScopeKey(d.rule.id, d.hit.transition) + const queued = enqueueTelegramOutbox({ + id: newOutboxId(), + dedupeKey: `${key}:${d.hit.payloadHash}`, + payload: { + text: `${pickTelegramAlertEmoji(d.hit.message)} ${d.hit.message}`, + chatId: d.rule.chatId?.trim() || undefined, + history: { + id: newHistoryId(), + ruleId: d.rule.id, + groupId: null, + ruleName: d.hit.ruleName, + severity: d.hit.severity, + message: d.hit.message, + firedAt, + }, + }, + }) + if (!queued) continue + standaloneFires += 1 + clearConfirmPendingAfterSend([d.rule.id]) + upsertEngineState(key, firedAt, d.hit.payloadHash) + state.set(key, { lastFiredAt: firedAt, lastPayloadHash: d.hit.payloadHash }) + } + + for (const d of grouped) { + if (!canSend) break + const firedAt = new Date().toISOString() + const lines = d.hits.map((h) => h.message) + const sev = d.hits.reduce((a, h) => maxSeverity(a, h.severity), d.hits[0]!.severity) + const body = `Группа «${d.group.name}» (${d.group.combineMode === "any" ? "ANY" : "ALL"})\n\n${lines.join("\n")}` + const hash = d.hits.map((h) => h.payloadHash).sort().join("|") + const key = `group:${d.group.id}` + const queued = enqueueTelegramOutbox({ + id: newOutboxId(), + dedupeKey: `${key}:${hash}`, + payload: { + text: `${pickTelegramAlertEmoji(body)} ${body}`, + history: { + id: newHistoryId(), + ruleId: null, + groupId: d.group.id, + ruleName: `Группа: ${d.group.name}`, + severity: sev, + message: body, + firedAt, + }, + }, + }) + if (!queued) continue + groupFires += 1 + upsertEngineState(key, firedAt, hash) + state.set(key, { lastFiredAt: firedAt, lastPayloadHash: hash }) + clearConfirmPendingAfterSend(d.members.map((r) => r.id)) + } + updateSourceWatermark(latestSourceFinishedAt) + } + + const outbox = await dispatchPendingOutbox() + errors.push(...outbox.errors) + + return { + sampledAt, + rulesChecked: hasNewSources ? rules.length : 0, + standaloneFires, + groupFires, + skippedNoTelegram: !canSend, + errors, + ruleDiag: ruleDiag ?? [], + } +} diff --git a/backend/src/services/alert-engine/signal-ingestor.ts b/backend/src/services/alert-engine/signal-ingestor.ts new file mode 100644 index 0000000..310e0c4 --- /dev/null +++ b/backend/src/services/alert-engine/signal-ingestor.ts @@ -0,0 +1,6 @@ +import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js" + +/** Единая точка входа для подготовки сигналов к rule matching. */ +export function ingestAlertSignals() { + return buildSignalSnapshotFromCollectors() +} diff --git a/backend/src/services/alert-engine/signals-from-collectors.ts b/backend/src/services/alert-engine/signals-from-collectors.ts new file mode 100644 index 0000000..b8500b7 --- /dev/null +++ b/backend/src/services/alert-engine/signals-from-collectors.ts @@ -0,0 +1,265 @@ +import { sqliteDatabase } from "../../db/index.js" +import type { + GreBgpSnapshotRunSnapshot, + PingRunSnapshot, + ResourcesRunSnapshot, + SchedulerRunSnapshot, + ServersRestPingRunSnapshot, + TrafficRunSnapshot, +} from "../../types/scheduler-run-snapshot.js" +import type { + BgpPeerSignal, + GreTunnelSignal, + ProbeSignal, + ServerSignal, + SignalSnapshot, + TrafficServerSignal, +} from "./types.js" + +function normalizeNameKey(v: string): string { + return v.trim().toLowerCase() +} + +function safeParseRunSnapshot(raw: string | null): SchedulerRunSnapshot | null { + if (!raw) return null + try { + return JSON.parse(raw) as SchedulerRunSnapshot + } catch { + return null + } +} + +function readLatestOkRunSnapshots(jobKey: string, limit = 32): SchedulerRunSnapshot[] { + const rows = sqliteDatabase + .prepare( + `SELECT result_json AS resultJson + FROM scheduler_runs + WHERE job_key = ? AND status = 'ok' AND result_json IS NOT NULL + ORDER BY finished_at DESC + LIMIT ?`, + ) + .all(jobKey, limit) as { resultJson: string | null }[] + const out: SchedulerRunSnapshot[] = [] + for (const row of rows) { + const parsed = safeParseRunSnapshot(row.resultJson) + if (!parsed || parsed.job !== jobKey) continue + out.push(parsed) + } + return out +} + +type TriState = "online" | "offline" | "degraded" + +function timelineToServerSignal(name: string, sampledAt: string, statuses: TriState[]): ServerSignal { + return { + name, + status: statuses[0] ?? "offline", + prevStatus: statuses[1] ?? null, + prev2Status: statuses[2] ?? null, + sampledAt, + } +} + +function collectServerSignalsFromResourceRuns(runs: ResourcesRunSnapshot[]): Map { + const byKey = new Map< + string, + { name: string; sampledAt: string; statuses: TriState[] } + >() + for (const run of runs) { + for (const s of run.servers ?? []) { + const name = String(s.name ?? "").trim() + if (!name) continue + const key = normalizeNameKey(name) + const rec = byKey.get(key) ?? { name, sampledAt: run.sampledAt, statuses: [] } + if (rec.statuses.length < 3) rec.statuses.push(s.status === "online" ? "online" : "offline") + if (!byKey.has(key)) rec.sampledAt = run.sampledAt + byKey.set(key, rec) + } + } + const out = new Map() + for (const [key, rec] of byKey) out.set(key, timelineToServerSignal(rec.name, rec.sampledAt, rec.statuses)) + return out +} + +function collectServerSignalsFromRestRuns(runs: ServersRestPingRunSnapshot[]): Map { + const byKey = new Map< + string, + { name: string; sampledAt: string; statuses: TriState[] } + >() + for (const run of runs) { + for (const s of run.servers ?? []) { + const name = String(s.name ?? "").trim() + if (!name) continue + const key = normalizeNameKey(name) + const rec = byKey.get(key) ?? { name, sampledAt: run.sampledAt, statuses: [] } + if (rec.statuses.length < 3) rec.statuses.push(s.ok ? "online" : "offline") + if (!byKey.has(key)) rec.sampledAt = run.sampledAt + byKey.set(key, rec) + } + } + const out = new Map() + for (const [key, rec] of byKey) out.set(key, timelineToServerSignal(rec.name, rec.sampledAt, rec.statuses)) + return out +} + +function mergeResourceAndRestServer(resource: ServerSignal, rest: ServerSignal): ServerSignal { + const rUp = resource.status === "online" + const tUp = rest.status === "online" + if (tUp && !rUp) { + const rBad = resource.status === "offline" || resource.status === "degraded" + if (rBad) { + return { + name: rest.name, + status: "online", + prevStatus: "online", + prev2Status: resource.status, + sampledAt: rest.sampledAt, + } + } + return rest + } + if (rUp && !tUp) return resource + if (tUp && rUp) { + if (rest.sampledAt >= resource.sampledAt) { + if (resource.prevStatus === "offline" || resource.prevStatus === "degraded") { + return { + name: rest.name, + status: "online", + prevStatus: "online", + prev2Status: resource.prevStatus === "degraded" ? "degraded" : "offline", + sampledAt: rest.sampledAt, + } + } + if ( + resource.prevStatus === "online" && + resource.prev2Status != null && + (resource.prev2Status === "offline" || resource.prev2Status === "degraded") + ) { + return { + name: rest.name, + status: "online", + prevStatus: "online", + prev2Status: resource.prev2Status, + sampledAt: rest.sampledAt, + } + } + } + return rest.sampledAt >= resource.sampledAt ? rest : resource + } + return rest.sampledAt >= resource.sampledAt ? rest : resource +} + +function collectProbeSignalsFromRuns(runs: PingRunSnapshot[]): ProbeSignal[] { + const out = new Map() + for (const run of runs) { + for (const p of run.probes ?? []) { + const key = `${String(p.name ?? "").trim()} → ${String(p.target ?? "").trim()}` + if (!key.trim() || out.has(key)) continue + out.set(key, { + key, + status: p.status, + rttMs: p.rttMs ?? null, + lossPct: p.lossPct ?? null, + sampledAt: run.sampledAt, + }) + } + } + return [...out.values()] +} + +function collectTrafficSignalsFromRuns(runs: TrafficRunSnapshot[]): TrafficServerSignal[] { + const out = new Map() + for (const run of runs) { + for (const s of run.servers ?? []) { + const name = String(s.name ?? "").trim() + if (!name) continue + const key = normalizeNameKey(name) + if (out.has(key)) continue + out.set(key, { + serverName: name, + rxMbps: Number(s.sumRxMbps ?? 0), + txMbps: Number(s.sumTxMbps ?? 0), + sampledAt: run.sampledAt, + }) + } + } + return [...out.values()] +} + +function collectGreSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): GreTunnelSignal[] { + const byTarget = new Map() + for (const run of runs) { + const greRows = run.greTunnels ?? [] + for (const row of greRows) { + const label = String(row.targetLabel ?? "").trim() + if (!label) continue + const prev = byTarget.get(label) + if (!prev) { + byTarget.set(label, { + targetLabel: label, + status: row.status, + prevStatus: null, + }) + continue + } + if (prev.prevStatus == null) prev.prevStatus = row.status + } + } + return [...byTarget.values()] +} + +function collectBgpSignalsFromRuns(runs: GreBgpSnapshotRunSnapshot[]): BgpPeerSignal[] { + const byKey = new Map() + for (const run of runs) { + const peers = run.bgpPeers ?? [] + for (const row of peers) { + const key = String(row.key ?? "").trim() + if (!key) continue + const prev = byKey.get(key) + if (!prev) { + byKey.set(key, { key, state: String(row.state ?? "").trim(), prevState: null }) + continue + } + if (prev.prevState == null) prev.prevState = String(row.state ?? "").trim() + } + } + return [...byKey.values()] +} + +/** Собирает сигналы alert_engine напрямую из результатов collector jobs (`scheduler_runs.result_json`). */ +export function buildSignalSnapshotFromCollectors(): SignalSnapshot { + const sampledAt = new Date().toISOString() + const resourceRuns = readLatestOkRunSnapshots("uptime_resources", 16) as ResourcesRunSnapshot[] + const restRuns = readLatestOkRunSnapshots("servers_rest_ping", 16) as ServersRestPingRunSnapshot[] + const pingRuns = readLatestOkRunSnapshots("uptime_ping", 24) as PingRunSnapshot[] + const trafficRuns = readLatestOkRunSnapshots("traffic", 8) as TrafficRunSnapshot[] + const greBgpRuns = readLatestOkRunSnapshots("gre_bgp", 8) as GreBgpSnapshotRunSnapshot[] + + const resourceByKey = collectServerSignalsFromResourceRuns(resourceRuns) + const restByKey = collectServerSignalsFromRestRuns(restRuns) + const serverKeys = new Set([...resourceByKey.keys(), ...restByKey.keys()]) + const servers: ServerSignal[] = [] + for (const key of serverKeys) { + const resource = resourceByKey.get(key) + const rest = restByKey.get(key) + if (!rest) { + if (resource) servers.push(resource) + continue + } + if (!resource) { + servers.push(rest) + continue + } + servers.push(mergeResourceAndRestServer(resource, rest)) + } + servers.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })) + + return { + sampledAt, + servers, + probes: collectProbeSignalsFromRuns(pingRuns), + trafficByServer: collectTrafficSignalsFromRuns(trafficRuns), + greTunnels: collectGreSignalsFromRuns(greBgpRuns), + bgpSessions: collectBgpSignalsFromRuns(greBgpRuns), + } +} diff --git a/backend/src/services/alert-engine/snapshot.ts b/backend/src/services/alert-engine/snapshot.ts new file mode 100644 index 0000000..f6fcff6 --- /dev/null +++ b/backend/src/services/alert-engine/snapshot.ts @@ -0,0 +1,7 @@ +import { buildSignalSnapshotFromCollectors } from "./signals-from-collectors.js" +import type { SignalSnapshot } from "./types.js" + +/** Legacy-совместимость: единый источник сигналов — collector snapshots из scheduler_runs. */ +export function buildSignalSnapshot(): SignalSnapshot { + return buildSignalSnapshotFromCollectors() +} diff --git a/backend/src/services/alert-engine/source-watermark.ts b/backend/src/services/alert-engine/source-watermark.ts new file mode 100644 index 0000000..b56528c --- /dev/null +++ b/backend/src/services/alert-engine/source-watermark.ts @@ -0,0 +1,54 @@ +import { desc, eq, inArray } from "drizzle-orm" +import { db } from "../../db/index.js" +import { alertEngineCursor, schedulerRuns } from "../../db/schema.js" + +const SOURCE_JOBS = [ + "traffic", + "servers_rest_ping", + "uptime_resources", + "uptime_ping", + "uptime_speed", + "gre_bgp", +] as const + +export function getLatestSourceFinishedAt(): string | null { + const row = db + .select({ finishedAt: schedulerRuns.finishedAt }) + .from(schedulerRuns) + .where(inArray(schedulerRuns.jobKey, [...SOURCE_JOBS])) + .orderBy(desc(schedulerRuns.finishedAt)) + .limit(1) + .all()[0] + return row?.finishedAt ?? null +} + +export function getSourceWatermark(): string | null { + const row = db.select().from(alertEngineCursor).where(eq(alertEngineCursor.id, 1)).limit(1).all()[0] + return row?.lastSourceFinishedAt ?? null +} + +export function updateSourceWatermark(lastSourceFinishedAt: string | null): void { + const existing = db + .select() + .from(alertEngineCursor) + .where(eq(alertEngineCursor.id, 1)) + .limit(1) + .all()[0] + if (existing) { + db.update(alertEngineCursor) + .set({ + lastSourceFinishedAt, + updatedAt: new Date().toISOString(), + }) + .where(eq(alertEngineCursor.id, 1)) + .run() + return + } + db.insert(alertEngineCursor) + .values({ + id: 1, + lastSourceFinishedAt, + updatedAt: new Date().toISOString(), + }) + .run() +} diff --git a/backend/src/services/alert-engine/state-machine.test.ts b/backend/src/services/alert-engine/state-machine.test.ts new file mode 100644 index 0000000..48dc367 --- /dev/null +++ b/backend/src/services/alert-engine/state-machine.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import { shouldAllowRecoveryByPolicy } from "./state-machine.js" + +const recoveryHit = { + ruleId: "r", + ruleName: "r", + severity: "warning" as const, + message: "восстановился", + transition: "recovery" as const, + payloadHash: "h1", +} + +const problemHit = { + ...recoveryHit, + transition: "problem" as const, +} + +assert.equal(shouldAllowRecoveryByPolicy(recoveryHit, "always"), true) +assert.equal(shouldAllowRecoveryByPolicy(recoveryHit, "conditional"), true) +assert.equal(shouldAllowRecoveryByPolicy(recoveryHit, "never"), false) +assert.equal(shouldAllowRecoveryByPolicy(problemHit, "never"), true) + +console.log("alert-engine state-machine tests ok") diff --git a/backend/src/services/alert-engine/state-machine.ts b/backend/src/services/alert-engine/state-machine.ts new file mode 100644 index 0000000..77fe4ea --- /dev/null +++ b/backend/src/services/alert-engine/state-machine.ts @@ -0,0 +1,16 @@ +import type { RuleEvalHit } from "./types.js" + +export type RecoveryMode = "always" | "never" | "conditional" + +export function isRecoveryTransition(hit: RuleEvalHit | null): boolean { + return hit?.transition === "recovery" +} + +export function shouldAllowRecoveryByPolicy( + hit: RuleEvalHit | null, + policy: RecoveryMode, +): boolean { + if (!isRecoveryTransition(hit)) return true + if (policy === "never") return false + return true +} diff --git a/backend/src/services/alert-engine/telegram-emoji.test.ts b/backend/src/services/alert-engine/telegram-emoji.test.ts new file mode 100644 index 0000000..1158a31 --- /dev/null +++ b/backend/src/services/alert-engine/telegram-emoji.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict" +import { isPositiveRecoveryTelegramText, pickTelegramAlertEmoji } from "./telegram-emoji.js" + +assert.equal(pickTelegramAlertEmoji("Сервер: x — снова в сети: x — восстановился"), "✅") +assert.equal(pickTelegramAlertEmoji("Сервер: x недоступен: x — перешёл в offline"), "🚨") +assert.equal(isPositiveRecoveryTelegramText("A — восстановился"), true) +assert.equal(isPositiveRecoveryTelegramText("A — перешёл в offline"), false) +assert.equal(isPositiveRecoveryTelegramText("BGP: p — восстановил сессию"), true) + +console.log("telegram-emoji tests ok") diff --git a/backend/src/services/alert-engine/telegram-emoji.ts b/backend/src/services/alert-engine/telegram-emoji.ts new file mode 100644 index 0000000..eee6aac --- /dev/null +++ b/backend/src/services/alert-engine/telegram-emoji.ts @@ -0,0 +1,27 @@ +/** + * Эмодзи в Telegram: восстановление / «подключился» / BGP session up — не сирена. + * Текст совпадает с форматом `evaluate-rule` (`… — условие`, несколько строк через \\n). + */ +function isRecoveryLine(line: string): boolean { + const t = line.toLowerCase() + return ( + /[—\-]\s*(восстанов|подключ)/i.test(t) || + /восстановил[аи]?\s+сессию/i.test(t) || + /снова\s+в\s+сети/i.test(t) + ) +} + +/** «Хорошие» переходы: не слать дубль при том же payloadHash (см. `run-once`). */ +export function isPositiveRecoveryTelegramText(fullText: string): boolean { + const lines = fullText + .split(/\n+/) + .map((l) => l.trim()) + .filter(Boolean) + if (lines.length === 0) return false + if (lines.length === 1) return isRecoveryLine(lines[0]!) + return lines.every(isRecoveryLine) +} + +export function pickTelegramAlertEmoji(fullText: string): string { + return isPositiveRecoveryTelegramText(fullText) ? "✅" : "🚨" +} diff --git a/backend/src/services/alert-engine/types.ts b/backend/src/services/alert-engine/types.ts new file mode 100644 index 0000000..2fe1b20 --- /dev/null +++ b/backend/src/services/alert-engine/types.ts @@ -0,0 +1,77 @@ +import type { ApiAlertRule } from "../alerts-service.js" + +export type ServerSignal = { + name: string + status: "online" | "offline" | "degraded" + /** Предпоследний сэмпл (для условий «восстановился» / «подключился») */ + prevStatus: "online" | "offline" | "degraded" | null + /** Третий с конца сэмпл — чтобы «восстановился» не висел истинным до второго online-сэмпла в БД. */ + prev2Status: "online" | "offline" | "degraded" | null + sampledAt: string +} + +export type ProbeSignal = { + /** Как в UI: `имя пробы → target` */ + key: string + status: "up" | "warn" | "down" + rttMs: number | null + lossPct: number | null + sampledAt: string +} + +export type TrafficServerSignal = { + serverName: string + rxMbps: number + txMbps: number + sampledAt: string +} + +export type GreTunnelSignal = { + /** Как в UI: `tunnelName / serverName` */ + targetLabel: string + status: "up" | "down" | "degraded" + /** Состояние на прошлом тике движка (из БД), для «восстановился». */ + prevStatus: "up" | "down" | "degraded" | null +} + +export type BgpPeerSignal = { + /** Как в UI: `serverName: peerName` */ + key: string + state: string + /** Состояние на прошлом тике (строка state с роутера). */ + prevState: string | null +} + +export type SignalSnapshot = { + sampledAt: string + servers: ServerSignal[] + probes: ProbeSignal[] + trafficByServer: TrafficServerSignal[] + greTunnels: GreTunnelSignal[] + bgpSessions: BgpPeerSignal[] +} + +export type RuleEvalHit = { + ruleId: string + ruleName: string + severity: ApiAlertRule["severity"] + message: string + /** Тип перехода для диагностики и логики задержки отправки. */ + transition: "problem" | "recovery" | "neutral" + /** Для дедупа */ + payloadHash: string +} + +import type { AlertEngineRuleDiagSnapshot } from "../../types/scheduler-run-snapshot.js" + +export type AlertEngineRuleDiag = AlertEngineRuleDiagSnapshot + +export type AlertEngineRunResult = { + sampledAt: string + rulesChecked: number + standaloneFires: number + groupFires: number + skippedNoTelegram: boolean + errors: string[] + ruleDiag: AlertEngineRuleDiag[] +} diff --git a/backend/src/services/alerts-service.ts b/backend/src/services/alerts-service.ts new file mode 100644 index 0000000..41556ce --- /dev/null +++ b/backend/src/services/alerts-service.ts @@ -0,0 +1,593 @@ +import { asc, desc, eq, sql } from "drizzle-orm" +import { db, sqliteDatabase } 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" +} + +function ensureTelegramRow() { + let row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0] + if (!row) { + db.insert(alertTelegramSettings).values({ id: 1 }).run() + row = db.select().from(alertTelegramSettings).where(eq(alertTelegramSettings.id, 1)).limit(1).all()[0] + } + return row! +} + +function lastFiredByRuleId(): Map { + const rows = sqliteDatabase + .prepare( + `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`, + ) + .all() as { ruleId: string; mx: string }[] + const m = new Map() + for (const r of rows) { + if (r.ruleId && r.mx) m.set(r.ruleId, r.mx) + } + return m +} + +function targetsByRuleId(): Map { + const rows = db + .select() + .from(alertRuleTargets) + .orderBy(asc(alertRuleTargets.sortIndex), asc(alertRuleTargets.id)) + .all() + const m = new Map() + for (const t of rows) { + const rid = t.ruleId + const arr = m.get(rid) ?? [] + arr.push(t.target) + m.set(rid, arr) + } + return m +} + +function conditionsByRuleId(): Map { + const rows = db + .select() + .from(alertRuleConditions) + .orderBy(asc(alertRuleConditions.sortIndex), asc(alertRuleConditions.id)) + .all() + const m = new Map() + 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 function listAlertGroups(): ApiAlertGroup[] { + return db + .select() + .from(alertGroups) + .orderBy(asc(alertGroups.name)) + .all() + .map((g) => ({ + id: g.id, + name: g.name, + combineMode: g.combineMode, + enabled: g.enabled, + cooldownOverride: g.cooldownOverride ?? null, + })) +} + +export function listAlertRules(): ApiAlertRule[] { + const lf = lastFiredByRuleId() + const tgtMap = targetsByRuleId() + const condMap = conditionsByRuleId() + const rows = db.select().from(alertRules).all() + 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 function replaceAlertsConfig(payload: { groups: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) { + const now = sql`(datetime('now'))` + const { groups, rules } = payload + db.transaction((tx) => { + tx.delete(alertRuleConditions).run() + tx.delete(alertRuleTargets).run() + tx.delete(alertRules).run() + tx.delete(alertGroups).run() + for (const g of groups) { + 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, + }).run() + } + 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 + 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, + }).run() + rawTargets.forEach((t, i) => { + tx.insert(alertRuleTargets).values({ + id: `rt-${r.id}-${i}`, + ruleId: r.id, + target: t, + sortIndex: i, + }).run() + }) + rawConds.forEach((c, i) => { + tx.insert(alertRuleConditions).values({ + id: `rc-${r.id}-${i}`, + ruleId: r.id, + conditionLine: c, + sortIndex: i, + }).run() + }) + } + }) +} + +/** Совместимость: только правила, группы не трогаем. */ +export function replaceAlertRules(rules: ReplaceAlertRuleInput[]) { + const curGroups = listAlertGroups() + replaceAlertsConfig({ groups: curGroups.map((g) => ({ ...g })), rules }) +} + +export function getTelegramSettingsRow() { + return ensureTelegramRow() +} + +export function getTelegramPublic() { + const row = 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 function updateTelegramSettings(patch: { + token?: string | null + chatId?: string | undefined + /** undefined — не менять; null — сбросить тему */ + messageThreadId?: number | null +}) { + const cur = 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`(datetime('now'))`, + } + if (nextThread !== undefined) { + db.update(alertTelegramSettings) + .set({ ...base, messageThreadId: nextThread }) + .where(eq(alertTelegramSettings.id, 1)) + .run() + } else { + db.update(alertTelegramSettings).set(base).where(eq(alertTelegramSettings.id, 1)).run() + } + return getTelegramPublic() +} + +/** Токен только для внутреннего вызова Telegram API (не отдаётся клиенту). */ +export function getTelegramBotToken(): string { + return ensureTelegramRow().botToken?.trim() ?? "" +} + +/** Для `sendMessage`: только если в БД задана валидная тема. */ +export function getTelegramMessageThreadIdForApi(): number | undefined { + const { messageThreadId } = 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() || getTelegramBotToken()).trim() + if (!token) return { ok: false, error: "Не задан Bot Token" } + const pub = 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 + : 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 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() + 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, + }) + .run() +} + +interface LiveResRow { + serverName: string + sampledAt: string +} + +interface LiveProbeRow { + probeName: string + target: string + sampledAt: string + status: string + rttMs: number | null + lossPct: number | null +} + +function loadLiveResourceIssues(): LiveResRow[] { + return sqliteDatabase + .prepare( + `SELECT s.name AS serverName, urs.sampled_at AS sampledAt + FROM uptime_resource_samples urs + INNER JOIN ( + SELECT server_id, MAX(id) AS mid FROM uptime_resource_samples GROUP BY server_id + ) latest ON latest.mid = urs.id + INNER JOIN servers s ON s.id = urs.server_id + WHERE urs.status = 'offline'`, + ) + .all() as LiveResRow[] +} + +function loadLiveProbeIssues(): LiveProbeRow[] { + return sqliteDatabase + .prepare( + `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 probe_id, MAX(id) AS mid FROM uptime_probe_samples GROUP BY probe_id + ) latest ON latest.mid = ups.id + INNER JOIN uptime_probes p ON p.id = ups.probe_id + WHERE ups.status IN ('down', 'warn')`, + ) + .all() as LiveProbeRow[] +} + +function toLiveHistoryEntries(): ApiAlertHistoryEntry[] { + const out: ApiAlertHistoryEntry[] = [] + for (const r of 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 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 function listPersistedHistory(limit: number): ApiAlertHistoryEntry[] { + const rows = db + .select() + .from(alertHistory) + .orderBy(desc(alertHistory.firedAt)) + .limit(limit) + .all() + 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 function listMergedHistory(persistLimit = 40, liveCap = 25): ApiAlertHistoryEntry[] { + const persisted = listPersistedHistory(persistLimit) + const live = 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() + 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 function getAlertsMeta() { + const srvRows = db + .select({ + name: servers.name, + host: servers.host, + site: servers.site, + country: servers.country, + type: servers.type, + }) + .from(servers) + .all() + 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 = db.select({ name: uptimeProbes.name, target: uptimeProbes.target }).from(uptimeProbes).all() + 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, + } +} diff --git a/backend/src/services/bgp-parse-sessions.ts b/backend/src/services/bgp-parse-sessions.ts new file mode 100644 index 0000000..7dce6ca --- /dev/null +++ b/backend/src/services/bgp-parse-sessions.ts @@ -0,0 +1,137 @@ +import { servers } from "../db/schema.js" +import type { BgpSessionRead, RosBgpSession } from "../types/server.js" + +type ServerRow = typeof servers.$inferSelect + +/** Parse RouterOS duration like "1m30s", "1d5h13m23s580ms", "30s" → seconds */ +function parseDuration(s: string | undefined): number { + if (!s) return 0 + let total = 0 + const matches = s.matchAll(/(\d+)(w|d|h|m(?!s)|s|ms)/g) + for (const m of matches) { + const n = parseInt(m[1]) + switch (m[2]) { + case "w": + total += n * 604800 + break + case "d": + total += n * 86400 + break + case "h": + total += n * 3600 + break + case "m": + total += n * 60 + break + case "s": + total += n + break + default: + break + } + } + return total +} + +function parseCapabilities(capStr: string | undefined): string[] { + if (!capStr) return [] + const codeMap: Record = { + mp: "MP-BGP", + rr: "Route Refresh", + gr: "Graceful Restart", + as4: "4-byte-AS", + enhe: "Extended Next-Hop", + role: "BGP Role", + err: "Extended Route Refresh", + llgr: "Long-Lived GR", + "add-path": "ADD-PATH", + } + return capStr + .split(",") + .map((c) => c.trim().toLowerCase()) + .filter(Boolean) + .map((c) => codeMap[c] ?? c) + .filter((v, i, a) => a.indexOf(v) === i) +} + +function detectState(s: RosBgpSession): string { + if (s["established"] === "true") return "Established" + + if (s["state"]) { + const map: Record = { + established: "Established", + active: "Active", + idle: "Idle", + connect: "Connect", + opensent: "OpenSent", + openconfirm: "OpenConfirm", + "open sent": "OpenSent", + "open confirm": "OpenConfirm", + } + const key = s["state"].trim().toLowerCase() + if (map[key]) return map[key] + for (const [k, v] of Object.entries(map)) { + if (key.startsWith(k)) return v + } + return key.charAt(0).toUpperCase() + key.slice(1) + } + + if (s["uptime"]) return "Active" + + return "Idle" +} + +/** Парсинг BGP-сессий с роутера — общая логика для HTTP-маршрута и alert-engine. */ +export function parseBgpSessions(server: ServerRow, raw: RosBgpSession[]): BgpSessionRead[] { + return raw.map((s, idx) => { + const remoteAs = parseInt(s["remote.as"] ?? "0") || 0 + const localAs = parseInt(s["local.as"] ?? "0") || 0 + const type: "eBGP" | "iBGP" = localAs > 0 && localAs === remoteAs ? "iBGP" : "eBGP" + + const localCaps = parseCapabilities(s["local.capabilities"]) + const remoteCaps = parseCapabilities(s["remote.capabilities"]) + const legacyCaps: string[] = [] + if (s["4-octet-as-capability"] === "true") legacyCaps.push("4-byte-AS") + if (s["as4-capability"] === "true") legacyCaps.push("4-byte-AS") + if (s["refresh-capability"] === "true") legacyCaps.push("Route Refresh") + if (s["add-path-capability"] === "true") legacyCaps.push("ADD-PATH") + if (s["graceful-restart-capability"] === "true") legacyCaps.push("Graceful Restart") + if (s["extended-message-capability"] === "true") legacyCaps.push("Extended Messages") + const caps = [...new Set([...localCaps, ...remoteCaps, ...legacyCaps])] + + const holdTime = parseDuration(s["hold-time"]) || parseInt(s["active-holdtime"] ?? "0") || 90 + + const keepalive = parseDuration(s["keepalive-time"]) || 30 + + const inputMessages = + parseInt(s["remote.messages"] ?? s["total-messages-received"] ?? "0") || 0 + const outputMessages = parseInt(s["local.messages"] ?? s["total-messages-sent"] ?? "0") || 0 + + const peerIp = (s["remote.address"] ?? "").replace(/\/\d+$/, "") + + return { + id: s[".id"] ?? String(idx), + serverId: server.id, + serverName: server.name || server.host, + serverSite: server.site, + serverCountry: server.country, + name: s["name"] ?? peerIp, + peerIp, + remoteAs, + localAs, + localId: s["local.id"] ?? "", + remoteId: s["remote.id"] ?? "", + state: detectState(s), + type, + uptime: s["uptime"] || null, + holdTime, + keepalive, + prefixesRx: parseInt(s["prefix-count"] ?? s["total-updates-received"] ?? "0") || 0, + prefixesTx: parseInt(s["total-updates-sent"] ?? "0") || 0, + inputMessages, + outputMessages, + capabilities: caps, + lastError: s["last-notification"] || null, + } + }) +} diff --git a/backend/src/services/bgp-peers-live.ts b/backend/src/services/bgp-peers-live.ts new file mode 100644 index 0000000..651101b --- /dev/null +++ b/backend/src/services/bgp-peers-live.ts @@ -0,0 +1,30 @@ +import { eq } from "drizzle-orm" +import { db } from "../db/index.js" +import { servers } from "../db/schema.js" +import type { BgpSessionRead } from "../types/server.js" +import { parseBgpSessions } from "./bgp-parse-sessions.js" +import { MikrotikClient } from "./mikrotik.js" + +/** Как в UI `/alerts`: `serverName: peerName`. */ +export function bgpPeerAlertKey(s: BgpSessionRead): string { + const sn = String(s.serverName ?? "").trim() + const nm = String(s.name ?? "").trim() + return `${sn}: ${nm}`.trim() +} + +/** Агрегат BGP-сессий с включённых серверов — как `/api/bgp/sessions`. */ +export async function fetchBgpSessionsForAlerts(): Promise { + const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all() + const results = await Promise.all( + allServers.map(async (server) => { + try { + const client = MikrotikClient.fromServer(server) + const raw = await client.getBgpSessions() + return parseBgpSessions(server, raw) + } catch { + return [] as BgpSessionRead[] + } + }), + ) + return results.flat() +} diff --git a/backend/src/services/btest-node-locks.ts b/backend/src/services/btest-node-locks.ts new file mode 100644 index 0000000..6d3535c --- /dev/null +++ b/backend/src/services/btest-node-locks.ts @@ -0,0 +1,25 @@ +/** Серийный доступ к bandwidth-test по node id (пара узлов не пересекаются). */ +const btestNodeLocks = new Map>() + +export async function withBtestNodeLocks(nodeIds: number[], fn: () => Promise): Promise { + const ids = [...new Set(nodeIds.filter((id) => Number.isFinite(id)))].sort((a, b) => a - b) + const releases: Array<() => void> = [] + try { + for (const id of ids) { + const prev = btestNodeLocks.get(id) + let resolveCurrent!: () => void + const current = new Promise((resolve) => { + resolveCurrent = resolve + }) + btestNodeLocks.set(id, current) + if (prev) await prev + releases.push(() => { + if (btestNodeLocks.get(id) === current) btestNodeLocks.delete(id) + resolveCurrent() + }) + } + return await fn() + } finally { + for (let i = releases.length - 1; i >= 0; i -= 1) releases[i]() + } +} diff --git a/backend/src/services/gre-bgp-snapshot-collector.ts b/backend/src/services/gre-bgp-snapshot-collector.ts new file mode 100644 index 0000000..f7cd7fb --- /dev/null +++ b/backend/src/services/gre-bgp-snapshot-collector.ts @@ -0,0 +1,100 @@ +import { db, sqliteDatabase } from "../db/index.js" +import { alertBgpPeerSamples, alertGreTunnelSamples } from "../db/schema.js" +import { bgpPeerAlertKey, fetchBgpSessionsForAlerts } from "./bgp-peers-live.js" +import { fetchGreTunnelLiveRows } from "./gre-tunnels-live.js" +import type { GreBgpSnapshotRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" + +let collecting = false + +export function getGreBgpSnapshotCollectorState(): { running: boolean } { + return { running: collecting } +} + +/** + * Один опрос GRE + BGP по включённым серверам и запись строк в SQLite для `buildSignalSnapshot`. + * Движок оповещений больше не дублирует эти REST-запросы. + */ +export async function collectGreBgpSnapshotOnce(): Promise { + const sampledAt = new Date().toISOString() + if (collecting) { + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "gre_bgp", + sampledAt, + skipped: true, + greWritten: 0, + bgpWritten: 0, + errors: [], + } + } + collecting = true + const errors: string[] = [] + const snapshot: GreBgpSnapshotRunSnapshot = { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "gre_bgp", + sampledAt, + greWritten: 0, + bgpWritten: 0, + greTunnels: [], + bgpPeers: [], + errors: [], + } + + try { + const [greSettled, bgpSettled] = await Promise.allSettled([ + fetchGreTunnelLiveRows(), + fetchBgpSessionsForAlerts(), + ]) + + if (greSettled.status === "rejected") { + const msg = + greSettled.reason instanceof Error ? greSettled.reason.message : String(greSettled.reason) + errors.push(`GRE: ${msg}`) + } + if (bgpSettled.status === "rejected") { + const msg = + bgpSettled.reason instanceof Error ? bgpSettled.reason.message : String(bgpSettled.reason) + errors.push(`BGP: ${msg}`) + } + + const greRows = greSettled.status === "fulfilled" ? greSettled.value : [] + const bgpRows = bgpSettled.status === "fulfilled" ? bgpSettled.value : [] + snapshot.greTunnels = greRows.map((r) => ({ targetLabel: r.targetLabel, status: r.status })) + snapshot.bgpPeers = bgpRows.map((s) => ({ key: bgpPeerAlertKey(s), state: s.state })) + + db.transaction((tx) => { + for (const r of greRows) { + tx.insert(alertGreTunnelSamples).values({ + sampledAt, + targetLabel: r.targetLabel, + status: r.status, + }).run() + snapshot.greWritten += 1 + } + for (const s of bgpRows) { + tx.insert(alertBgpPeerSamples).values({ + sampledAt, + peerKey: bgpPeerAlertKey(s), + state: s.state, + }).run() + snapshot.bgpWritten += 1 + } + }) + + sqliteDatabase + .prepare(`DELETE FROM alert_gre_tunnel_samples WHERE sampled_at < datetime('now', '-30 days')`) + .run() + sqliteDatabase + .prepare(`DELETE FROM alert_bgp_peer_samples WHERE sampled_at < datetime('now', '-30 days')`) + .run() + + if (errors.length) snapshot.errors = errors + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + snapshot.fatalError = msg + } finally { + collecting = false + } + return snapshot +} diff --git a/backend/src/services/gre-tunnels-live.ts b/backend/src/services/gre-tunnels-live.ts new file mode 100644 index 0000000..9691704 --- /dev/null +++ b/backend/src/services/gre-tunnels-live.ts @@ -0,0 +1,60 @@ +import { eq } from "drizzle-orm" +import { db } from "../db/index.js" +import { servers } from "../db/schema.js" +import { MikrotikClient } from "./mikrotik.js" + +type ServerRow = typeof servers.$inferSelect + +interface RosGre { + ".id"?: string + name?: string + "local-address"?: string + "remote-address"?: string + "allow-fast-path"?: string + "clamp-tcp-mss"?: string + mtu?: string + "keepalive"?: string + dscp?: string + running?: string + disabled?: string + comment?: string +} + +export type GreTunnelLiveStatus = "up" | "down" | "degraded" + +/** Как в UI `/alerts`: `${tunnelName} / ${serverName}`. */ +export type GreTunnelLiveRow = { + targetLabel: string + status: GreTunnelLiveStatus +} + +function mapGreRow(server: ServerRow, g: RosGre, idx: number): GreTunnelLiveRow { + const running = g.running === "true" + const disabled = g.disabled === "true" + const status: GreTunnelLiveStatus = disabled ? "down" : running ? "up" : "degraded" + const sn = String(server.name ?? "").trim() || String(server.host ?? "").trim() || String(server.id) + const tn = (String(g.name ?? "").trim() || String(g[".id"] ?? "").trim() || `gre-${idx + 1}`).trim() + const label = `${tn} / ${sn}`.trim() + return { targetLabel: label, status } +} + +async function fetchGreForServer(server: ServerRow): Promise { + const client = MikrotikClient.fromServer(server) + const greRaw = await client.get("/interface/gre") + return greRaw.map((g, idx) => mapGreRow(server, g, idx)) +} + +/** GRE с включённых серверов — тот же смысл, что `/api/filters/gre-tunnels` (без правил фильтров). */ +export async function fetchGreTunnelLiveRows(): Promise { + const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() + const results = await Promise.all( + enabledServers.map(async (server) => { + try { + return await fetchGreForServer(server) + } catch { + return [] as GreTunnelLiveRow[] + } + }), + ) + return results.flat() +} diff --git a/backend/src/services/mikrotik.ts b/backend/src/services/mikrotik.ts index 3fa118a..41f0968 100644 --- a/backend/src/services/mikrotik.ts +++ b/backend/src/services/mikrotik.ts @@ -1,6 +1,7 @@ import http from "node:http" import https from "node:https" import type { Server } from "../db/schema.js" +import { parseRosDataSizeBytes } from "./ros-metric-parse.js" import type { RosIdentity, RosInterface, RosIpAddress, RosResource, RosBgpSession, @@ -199,6 +200,76 @@ function rosDelete( }) } +function rosPatch( + params: MikrotikConnectParams, + path: string, + body: Record, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const basePath = params.apiPath ?? "/rest" + const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64") + const payload = JSON.stringify(body) + + const options: https.RequestOptions = { + hostname: params.host, + port: params.port, + path: basePath + path, + method: "PATCH", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + rejectUnauthorized: params.useSsl ? params.verifySsl : undefined, + } + + const lib = params.useSsl ? https : http + const timer = setTimeout(() => { + req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`)) + }, timeoutMs) + + const req = lib.request(options, (res) => { + let buf = "" + res.setEncoding("utf8") + res.on("data", (chunk: string) => { buf += chunk }) + res.on("end", () => { + clearTimeout(timer) + if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { + reject(new MikrotikError(res.statusCode ?? 0, path, buf)); return + } + if (buf.trim() === "") { + resolve({}) + return + } + try { resolve(JSON.parse(buf)) } catch { resolve({}) } + }) + }) + + req.on("error", (err) => { + clearTimeout(timer) + reject(err) + }) + req.write(payload) + req.end() + }) +} + +/** + * RouterOS REST для одиночных меню (`/system/resource`, `/system/identity`) отдаёт JSON-массив из одного объекта `[{...}]`, + * а не сам объект. Без распаковки поля читаются с массива → всегда undefined и в БД уходят нули при «успехе». + * @see https://help.mikrotik.com/docs/spaces/ROS/pages/47579162/REST+API + */ +function unwrapRestSingle(raw: unknown): T { + if (Array.isArray(raw)) { + const first = raw[0] + if (first && typeof first === "object") return first as T + throw new Error("RouterOS REST: пустой массив вместо записи") + } + if (raw && typeof raw === "object") return raw as T + throw new Error("RouterOS REST: неожиданное тело ответа") +} + // ── MikrotikClient ───────────────────────────────────────────────────────────── export class MikrotikClient { @@ -228,14 +299,43 @@ export class MikrotikClient { return rosDelete(this.params, path, timeoutMs) } + async patch(path: string, body: Record, timeoutMs = 15_000): Promise { + return rosPatch(this.params, path, body, timeoutMs) as Promise + } + // ── typed helpers ────────────────────────────────────────────────────────── async getIdentity(): Promise { - return this.get("/system/identity") + const raw = await this.get("/system/identity") + return unwrapRestSingle(raw) } async getResource(): Promise { - return this.get("/system/resource") + const raw = await this.get("/system/resource") + if (Array.isArray(raw)) { + if (raw.length === 0) throw new Error("RouterOS REST: пустой массив вместо записи /system/resource") + if (raw.length === 1) return unwrapRestSingle(raw[0]) + /** Несколько объектов: взять строку с total-memory (агрегат), а не дочернюю запись с «чужим» uptime. */ + const objs = raw.filter((x): x is Record => x != null && typeof x === "object" && !Array.isArray(x)) + /** На части железа несколько строк; «первая с непустым total-memory» может парситься в 0 — берём запись с максимальным RAM. */ + let best: Record | undefined + let bestBytes = -1 + for (const o of objs) { + const tm = parseRosDataSizeBytes(o["total-memory"]) + const fm = parseRosDataSizeBytes(o["free-memory"]) + const score = tm > 0 ? tm : fm + if (score > bestBytes) { + bestBytes = score + best = o + } + } + const main = objs.find((o) => { + const tm = o["total-memory"] + return tm != null && String(tm).trim() !== "" + }) + return ((bestBytes > 0 ? best : undefined) ?? main ?? objs[0]) as unknown as RosResource + } + return unwrapRestSingle(raw) } async getInterfaces(): Promise { @@ -277,6 +377,11 @@ export class MikrotikClient { return this.get("/routing/bfd/session") } + async setOspfInterfaceTemplateCost(templateId: string, cost: number): Promise { + const safeId = templateId.startsWith("*") ? templateId : `*${templateId.replace(/^\*/, "")}` + await this.patch(`/routing/ospf/interface-template/${encodeURIComponent(safeId)}`, { cost: String(cost) }) + } + // ── extra endpoints for exec route ──────────────────────────────────────── async getIpRoutes(): Promise { diff --git a/backend/src/services/poller.ts b/backend/src/services/poller.ts index 3017f4a..9ec48ca 100644 --- a/backend/src/services/poller.ts +++ b/backend/src/services/poller.ts @@ -4,6 +4,7 @@ import { servers, serverSnapshots } from "../db/schema.js" import type { SnapshotInsert } from "../db/schema.js" import type { SnapshotRead } from "../types/server.js" import { MikrotikClient } from "./mikrotik.js" +import { parseRosCpuLoadPercent, parseRosDataSizeBytes } from "./ros-metric-parse.js" // ── pollServer ───────────────────────────────────────────────────────────────── @@ -45,10 +46,9 @@ export async function pollServer(serverId: number): Promise { const latencyMs = performance.now() - t0 - // Parse RouterOS string values (RouterOS REST API returns everything as strings) - const cpuLoad = parseInt(resource["cpu-load"], 10) - const freeMem = parseInt(resource["free-memory"], 10) - const totalMem = parseInt(resource["total-memory"], 10) + const cpuLoad = parseRosCpuLoadPercent(resource["cpu-load"]) + const freeMem = parseRosDataSizeBytes(resource["free-memory"]) + const totalMem = parseRosDataSizeBytes(resource["total-memory"]) Object.assign(partialSnap, { status: "online", @@ -57,9 +57,9 @@ export async function pollServer(serverId: number): Promise { rosVersion: resource["version"], boardName: resource["board-name"], uptime: resource["uptime"], - cpuLoad: isNaN(cpuLoad) ? null : cpuLoad, - freeMemory: isNaN(freeMem) ? null : freeMem, - totalMemory: isNaN(totalMem) ? null : totalMem, + cpuLoad, + freeMemory: freeMem, + totalMemory: totalMem, rawInterfaces: JSON.stringify(ifaces), rawIpAddresses: JSON.stringify(addresses), } satisfies Partial) diff --git a/backend/src/services/ros-metric-parse.ts b/backend/src/services/ros-metric-parse.ts new file mode 100644 index 0000000..c344ef1 --- /dev/null +++ b/backend/src/services/ros-metric-parse.ts @@ -0,0 +1,106 @@ +/** + * Разбор полей /system/resource из RouterOS REST: не всегда «сырые» байты в виде десятичной строки. + * На железе часто встречаются значения вида "224.0MiB", "94.2MiB"; parseInt от этого даёт мусор или 0. + */ + +export function parseRosDataSizeBytes(raw: unknown): number { + if (raw == null) return 0 + if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(0, Math.floor(raw)) + let s = String(raw).trim().replace(/\s+/g, "") + if (!s) return 0 + if (/^\d+$/.test(s)) { + const n = Number.parseInt(s, 10) + return Number.isFinite(n) ? Math.max(0, n) : 0 + } + const m = s.match(/^(\d+(?:[.,]\d+)?)(KiB|MiB|GiB|TiB|Ki|Mi|Gi|Ti|KB|MB|GB|TB)$/i) + if (m) { + const v = Number.parseFloat(m[1].replace(",", ".")) + if (!Number.isFinite(v) || v < 0) return 0 + const u = m[2].toLowerCase() + const mult = + u === "ki" || u === "kib" || u === "kb" ? 1024 + : u === "mi" || u === "mib" || u === "mb" ? 1024 ** 2 + : u === "gi" || u === "gib" || u === "gb" ? 1024 ** 3 + : u === "ti" || u === "tib" || u === "tb" ? 1024 ** 4 + : 1 + return Math.max(0, Math.floor(v * mult)) + } + const fallback = Number.parseInt(s, 10) + return Number.isFinite(fallback) ? Math.max(0, fallback) : 0 +} + +/** cpu-load: "7%", "7", 7 */ +export function parseRosCpuLoadPercent(raw: unknown): number { + if (raw == null) return 0 + if (typeof raw === "number" && Number.isFinite(raw)) { + return Math.min(100, Math.max(0, Math.round(raw))) + } + const s = String(raw).trim().replace("%", "").replace(",", ".").trim() + if (!s) return 0 + const n = Number.parseFloat(s) + if (!Number.isFinite(n)) return 0 + return Math.min(100, Math.max(0, Math.round(n))) +} + +/** + * `uptime` из /system/resource (REST): обычно "2d20h12m20s", "39m", "29s". + * Встречаются варианты: только секунды строкой, `d.hh:mm:ss`, `hh:mm:ss` без суффиксов. + * @see https://help.mikrotik.com/docs/display/ROS/Resource + */ +export function parseRosUptimeToSeconds(raw: unknown): number { + if (raw == null) return 0 + if (typeof raw === "number" && Number.isFinite(raw)) { + return Math.max(0, Math.floor(raw)) + } + const s0 = String(raw).trim().replace(/\u00a0/g, " ").trim() + if (!s0) return 0 + + // Только десятичные цифры — секунды (некоторые клиенты/прокси) + if (/^\d+$/.test(s0)) { + const n = Number.parseInt(s0, 10) + return Number.isFinite(n) ? Math.max(0, n) : 0 + } + + const s = s0.toLowerCase().replace(/\s+/g, "") + + // 1d18:27:15 + const dClock = s.match(/^(\d+)d(\d{1,3}):(\d{2}):(\d{2})$/i) + if (dClock) { + const d = Number.parseInt(dClock[1], 10) + const hh = Number.parseInt(dClock[2], 10) + const mm = Number.parseInt(dClock[3], 10) + const ss = Number.parseInt(dClock[4], 10) + if ([d, hh, mm, ss].every((x) => Number.isFinite(x) && x >= 0)) { + return d * 86400 + hh * 3600 + mm * 60 + ss + } + } + + // 18:27:15 — только если нет суффиксов w/d/h/m/s (иначе это часть "2d20h12m20s") + if (!/[wdhms]/i.test(s0) && /^\d{1,7}:\d{2}:\d{2}$/.test(s)) { + const p = s.split(":") + if (p.length === 3) { + const hh = Number.parseInt(p[0], 10) + const mm = Number.parseInt(p[1], 10) + const sec = Number.parseInt(p[2], 10) + if ([hh, mm, sec].every((x) => Number.isFinite(x) && x >= 0)) { + return hh * 3600 + mm * 60 + sec + } + } + } + + let total = 0 + for (const m of s.matchAll(/(\d+)(w|d|h|m(?!s)|s)/gi)) { + const n = Number.parseInt(m[1], 10) + if (!Number.isFinite(n) || n < 0) continue + const u = m[2].toLowerCase() + switch (u) { + case "w": total += n * 604800; break + case "d": total += n * 86400; break + case "h": total += n * 3600; break + case "m": total += n * 60; break + case "s": total += n; break + default: break + } + } + return Math.max(0, total) +} diff --git a/backend/src/services/scheduler-running.ts b/backend/src/services/scheduler-running.ts new file mode 100644 index 0000000..9aa5f77 --- /dev/null +++ b/backend/src/services/scheduler-running.ts @@ -0,0 +1,37 @@ +/** + * Реестр активных джоб планировщика (без импорта `scheduler.ts` → нет цикла с `run-once`). + * Слот берётся до `await` в теле джобы — так `alert_engine` может дождаться REST и др. до чтения БД. + */ +const runningSchedulerJobs = new Set() + +const ALERT_SNAPSHOT_SOURCE_JOB_KEYS = new Set([ + "traffic", + "servers_rest_ping", + "uptime_resources", + "uptime_ping", + "uptime_speed", + "gre_bgp", +]) + +/** true, если слот занят (джоба уже выполняется). */ +export function tryBeginSchedulerJob(jobKey: string): boolean { + if (runningSchedulerJobs.has(jobKey)) return false + runningSchedulerJobs.add(jobKey) + return true +} + +export function endSchedulerJob(jobKey: string): void { + runningSchedulerJobs.delete(jobKey) +} + +export function isSchedulerJobRunning(jobKey: string): boolean { + return runningSchedulerJobs.has(jobKey) +} + +/** Есть ли коллектор, который пишет таблицы снимка для `buildSignalSnapshot`. */ +export function isAnyAlertSnapshotSourceJobRunning(): boolean { + for (const k of ALERT_SNAPSHOT_SOURCE_JOB_KEYS) { + if (runningSchedulerJobs.has(k)) return true + } + return false +} diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts new file mode 100644 index 0000000..684fcdb --- /dev/null +++ b/backend/src/services/scheduler.ts @@ -0,0 +1,362 @@ +/** + * In-process планировщик: отдельный интервал на job (`refreshScheduler`). + * + * **Приоритеты при конкуренции** (для дальнейшего per-server mutex; сейчас зафиксировано в дизайне): + * 1. `uptime_ping` — выше (короткий критичный сигнал). + * 2. `traffic`, `uptime_resources`, `servers_rest_ping` — средний (REST `/system/identity` по каталогу). + * 3. `uptime_speed` — ниже (BW-test, тяжёлый); локи узлов — `withBtestNodeLocks` в speed-сервисе. + * + * **Оповещения (`alert_engine`):** читают SQLite после джоб сбора (см. `buildSignalSnapshot`), в т.ч. + * `gre_bgp` → `alert_gre_tunnel_samples` / `alert_bgp_peer_samples`. После успешного завершения джоб + * `traffic`, `uptime_*`, `servers_rest_ping`, `gre_bgp` планируется **дополнительный** прогон движка + * (debounce), см. [`alert-collector-hooks.ts`](./alert-collector-hooks.ts); любой другой писатель сэмплов + * для снимка оповещений тоже должен вызывать `scheduleAlertEngineAfterDataCollectors()` после коммита. + * + * **Кастомные job без произвольного кода (MVP):** пер-тайминг ping в `uptime_probes.interval_sec`; + * таблица `scheduler_job_instances` (kind + config JSON) — при необходимости следующий этап. + */ +import { desc, eq, lt } from "drizzle-orm" +import { db } from "../db/index.js" +import { schedulerRuns } from "../db/schema.js" +import type { SchedulerRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" +import { + collectServersRestPingOnce, + getServersApiPingSettings, +} from "./servers-rest-ping-collector.js" +import { collectTrafficOnce, getTrafficSettings } from "./traffic-collector.js" +import { + collectPingProbesOnce, + collectResourceSamplesOnce, + getSettings as getUptimeSettings, +} from "./uptime-collector.js" +import { runScheduledSpeedProbesOnce } from "./uptime-speed-service.js" +import { collectGreBgpSnapshotOnce } from "./gre-bgp-snapshot-collector.js" +import { runAlertEngineOnce } from "./alert-engine/run-once.js" +import { + clearAlertCollectorHooksTimer, + scheduleAlertEngineAfterDataCollectors, + wireAlertEngineRunner, +} from "./alert-collector-hooks.js" +import { + endSchedulerJob, + isSchedulerJobRunning, + tryBeginSchedulerJob, +} from "./scheduler-running.js" + +export const JOB_KEYS = [ + "traffic", + "servers_rest_ping", + "uptime_resources", + "uptime_ping", + "uptime_speed", + "gre_bgp", + "alert_engine", +] as const +export type SchedulerJobKey = (typeof JOB_KEYS)[number] + +const timers = new Map>() + +const RUN_LOG_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 + +function newRunId(): string { + return `sch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function appendSchedulerRun(row: { + jobKey: string + startedAt: string + finishedAt: string + status: "ok" | "error" + error: string | null + durationMs: number + result?: SchedulerRunSnapshot | null +}) { + db.insert(schedulerRuns).values({ + id: newRunId(), + jobKey: row.jobKey, + startedAt: row.startedAt, + finishedAt: row.finishedAt, + status: row.status, + error: row.error, + durationMs: row.durationMs, + resultJson: row.result ? JSON.stringify(row.result) : null, + }).run() + const cutoff = new Date(Date.now() - RUN_LOG_RETENTION_MS).toISOString() + db.delete(schedulerRuns).where(lt(schedulerRuns.finishedAt, cutoff)).run() +} + +async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise { + const startedAt = Date.now() + const startedIso = new Date().toISOString() + let snapshot: SchedulerRunSnapshot | undefined + try { + switch (jobKey) { + case "traffic": + snapshot = await collectTrafficOnce() + break + case "uptime_resources": + snapshot = await collectResourceSamplesOnce() + break + case "uptime_ping": + snapshot = await collectPingProbesOnce() + break + case "uptime_speed": + snapshot = await runScheduledSpeedProbesOnce() + break + case "servers_rest_ping": + snapshot = await collectServersRestPingOnce() + break + case "gre_bgp": + snapshot = await collectGreBgpSnapshotOnce() + break + case "alert_engine": { + const r = await runAlertEngineOnce() + snapshot = { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "alert_engine", + sampledAt: r.sampledAt, + rulesChecked: r.rulesChecked, + standaloneFires: r.standaloneFires, + groupFires: r.groupFires, + skippedNoTelegram: r.skippedNoTelegram, + ...(r.ruleDiag.length ? { ruleDiag: r.ruleDiag } : {}), + ...(r.errors.length ? { errors: r.errors } : {}), + } + break + } + default: + throw new Error(`Unknown job: ${jobKey}`) + } + const finishedIso = new Date().toISOString() + appendSchedulerRun({ + jobKey, + startedAt: startedIso, + finishedAt: finishedIso, + status: "ok", + error: null, + durationMs: Date.now() - startedAt, + result: snapshot ?? null, + }) + if ( + jobKey === "traffic" || + jobKey === "servers_rest_ping" || + jobKey === "uptime_resources" || + jobKey === "uptime_ping" || + jobKey === "uptime_speed" || + jobKey === "gre_bgp" + ) { + scheduleAlertEngineAfterDataCollectors() + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + const finishedIso = new Date().toISOString() + appendSchedulerRun({ + jobKey, + startedAt: startedIso, + finishedAt: finishedIso, + status: "error", + error: msg, + durationMs: Date.now() - startedAt, + result: snapshot ?? null, + }) + throw e + } +} + +/** Фоновый тик: пропуск, если предыдущий прогон ещё идёт. */ +export async function executeSchedulerJob(jobKey: SchedulerJobKey): Promise { + if (!tryBeginSchedulerJob(jobKey)) return + try { + await runSchedulerJobBody(jobKey) + } catch { + /* залогировано в runSchedulerJobBody */ + } finally { + endSchedulerJob(jobKey) + } +} + +/** Ручной запуск: 409, если job уже выполняется. */ +export async function runSchedulerJobNow(jobKey: string): Promise { + if (!JOB_KEYS.includes(jobKey as SchedulerJobKey)) { + throw new Error(`Unknown job key: ${jobKey}`) + } + if (!tryBeginSchedulerJob(jobKey)) { + const err = new Error("Job already running") + ;(err as Error & { statusCode?: number }).statusCode = 409 + throw err + } + try { + await runSchedulerJobBody(jobKey as SchedulerJobKey) + } finally { + endSchedulerJob(jobKey) + } +} + +function clearAllTimers() { + clearAlertCollectorHooksTimer() + for (const t of timers.values()) clearInterval(t) + timers.clear() +} + +/** Пересоздать интервалы после смены настроек. */ +export function refreshScheduler(): void { + clearAllTimers() + + const traffic = getTrafficSettings() + if (traffic.enabled) { + const ms = Math.max(5_000, traffic.intervalSec * 1000) + void executeSchedulerJob("traffic").catch(() => {}) + timers.set( + "traffic", + setInterval(() => { + void executeSchedulerJob("traffic").catch(() => {}) + }, ms), + ) + } + + const apiPing = getServersApiPingSettings() + if (apiPing.enabled) { + const apiMs = Math.max(10_000, apiPing.intervalSec * 1000) + void executeSchedulerJob("servers_rest_ping").catch(() => {}) + timers.set( + "servers_rest_ping", + setInterval(() => { + void executeSchedulerJob("servers_rest_ping").catch(() => {}) + }, apiMs), + ) + } + + const uptime = getUptimeSettings() + const resOn = uptime.resourcesEnabled ?? uptime.enabled + const pingOn = uptime.pingEnabled ?? uptime.enabled + const spdOn = uptime.speedEnabled ?? uptime.enabled + + if (resOn) { + const resMs = Math.max(5_000, uptime.intervalSec * 1000) + void executeSchedulerJob("uptime_resources").catch(() => {}) + timers.set( + "uptime_resources", + setInterval(() => { + void executeSchedulerJob("uptime_resources").catch(() => {}) + }, resMs), + ) + } + + if (pingOn) { + const pingMs = Math.max(1_000, uptime.probeIntervalSec * 1000) + void executeSchedulerJob("uptime_ping").catch(() => {}) + timers.set( + "uptime_ping", + setInterval(() => { + void executeSchedulerJob("uptime_ping").catch(() => {}) + }, pingMs), + ) + } + + if (spdOn) { + const spdMs = Math.max(10_000, uptime.speedIntervalSec * 1000) + void executeSchedulerJob("uptime_speed").catch(() => {}) + timers.set( + "uptime_speed", + setInterval(() => { + void executeSchedulerJob("uptime_speed").catch(() => {}) + }, spdMs), + ) + } + + const greBgpMs = 30_000 + void executeSchedulerJob("gre_bgp").catch(() => {}) + timers.set( + "gre_bgp", + setInterval(() => { + void executeSchedulerJob("gre_bgp").catch(() => {}) + }, greBgpMs), + ) + + const alertMs = 20_000 + void executeSchedulerJob("alert_engine").catch(() => {}) + timers.set( + "alert_engine", + setInterval(() => { + void executeSchedulerJob("alert_engine").catch(() => {}) + }, alertMs), + ) +} + +export function stopScheduler(): void { + clearAllTimers() +} + +export function isJobRunning(jobKey: string): boolean { + return isSchedulerJobRunning(jobKey) +} + +function lastRunForJob(jobKey: string) { + return db.select().from(schedulerRuns) + .where(eq(schedulerRuns.jobKey, jobKey)) + .orderBy(desc(schedulerRuns.finishedAt)) + .limit(1) + .all()[0] +} + +export function getSchedulerStatus() { + const traffic = getTrafficSettings() + const uptime = getUptimeSettings() + const apiPing = getServersApiPingSettings() + + const resOn = uptime.resourcesEnabled ?? uptime.enabled + const pingOn = uptime.pingEnabled ?? uptime.enabled + const spdOn = uptime.speedEnabled ?? uptime.enabled + + const jobMeta: Record = { + traffic: { enabled: traffic.enabled, intervalSec: traffic.intervalSec }, + servers_rest_ping: { enabled: apiPing.enabled, intervalSec: apiPing.intervalSec }, + uptime_resources: { enabled: resOn, intervalSec: uptime.intervalSec }, + uptime_ping: { enabled: pingOn, intervalSec: uptime.probeIntervalSec }, + uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec }, + gre_bgp: { enabled: true, intervalSec: 30 }, + alert_engine: { enabled: true, intervalSec: 20 }, + } + + return { + jobs: JOB_KEYS.map((jobKey) => { + const last = lastRunForJob(jobKey) + const m = jobMeta[jobKey] + return { + jobKey, + enabled: m.enabled, + intervalSec: m.intervalSec, + running: isSchedulerJobRunning(jobKey), + lastFinishedAt: last?.finishedAt ?? null, + lastStatus: last?.status ?? null, + lastDurationMs: last?.durationMs ?? null, + lastError: last?.error ?? null, + } + }), + } +} + +export function listSchedulerRuns(opts: { jobKey?: string; limit: number; offset: number }) { + const limit = Math.min(200, Math.max(1, opts.limit)) + const offset = Math.max(0, opts.offset) + if (opts.jobKey) { + return { + runs: db.select().from(schedulerRuns) + .where(eq(schedulerRuns.jobKey, opts.jobKey)) + .orderBy(desc(schedulerRuns.finishedAt)) + .limit(limit) + .offset(offset) + .all(), + } + } + return { + runs: db.select().from(schedulerRuns) + .orderBy(desc(schedulerRuns.finishedAt)) + .limit(limit) + .offset(offset) + .all(), + } +} + +wireAlertEngineRunner(() => executeSchedulerJob("alert_engine")) diff --git a/backend/src/services/servers-rest-ping-collector.ts b/backend/src/services/servers-rest-ping-collector.ts new file mode 100644 index 0000000..c96f0db --- /dev/null +++ b/backend/src/services/servers-rest-ping-collector.ts @@ -0,0 +1,202 @@ +import { eq, or } from "drizzle-orm" +import { db, sqliteDatabase } from "../db/index.js" +import { servers, serversApiPingSettings, serversRestPingSamples } from "../db/schema.js" +import type { ServersRestPingRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" +import { MikrotikClient } from "./mikrotik.js" + +let collecting = false + +function getSettingsRow() { + const row = db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1).all()[0] + if (row) return row + const now = new Date().toISOString() + db.insert(serversApiPingSettings).values({ + id: 1, + enabled: false, + intervalSec: 120, + createdAt: now, + updatedAt: now, + }).run() + return db.select().from(serversApiPingSettings).where(eq(serversApiPingSettings.id, 1)).limit(1).all()[0] +} + +/** Как uptime: включённые в каталоге или Home Router (часто «выкл.» при доступе только из LAN). */ +function serversForRestProbe() { + return db + .select() + .from(servers) + .where(or(eq(servers.enabled, true), eq(servers.type, "home-router"))) + .all() +} + +export function getServersApiPingSettings() { + return getSettingsRow() +} + +export function updateServersApiPingSettings(patch: { enabled?: boolean; intervalSec?: number }) { + const prev = getSettingsRow() + const next = { + enabled: patch.enabled ?? prev.enabled, + intervalSec: patch.intervalSec ?? prev.intervalSec, + updatedAt: new Date().toISOString(), + } + db.update(serversApiPingSettings).set(next).where(eq(serversApiPingSettings.id, 1)).run() + return getSettingsRow() +} + +export function getServersRestPingCollectorState(): { running: boolean } { + return { running: collecting } +} + +function loadLastRestSampleByServer(): Map { + const rows = sqliteDatabase + .prepare( + `SELECT s.server_id AS serverId, s.ok + FROM servers_rest_ping_samples s + INNER JOIN ( + SELECT server_id, MAX(id) AS mid FROM servers_rest_ping_samples GROUP BY server_id + ) t ON t.mid = s.id`, + ) + .all() as { serverId: number; ok: number }[] + const m = new Map() + for (const r of rows) m.set(r.serverId, { ok: Boolean(r.ok) }) + return m +} + +/** Последний сэмпл ресурсов — для согласования «восстановился» с REST без второго тика uptime. */ +function loadLatestResourceDownServerIds(): Set { + const rows = sqliteDatabase + .prepare( + `WITH r AS ( + SELECT urs.server_id AS serverId, urs.status, + ROW_NUMBER() OVER (PARTITION BY urs.server_id ORDER BY urs.id DESC) AS rn + FROM uptime_resource_samples urs + ) + SELECT serverId FROM r WHERE rn = 1 AND status IN ('offline', 'degraded')`, + ) + .all() as { serverId: number }[] + return new Set(rows.map((x) => x.serverId)) +} + +/** + * Лёгкая проверка: GET `/system/identity` (REST) на узел, замер RTT. + * Пишет строки в `servers_rest_ping_samples` для алерт-движка (см. `buildSignalSnapshot`). + */ +export async function collectServersRestPingOnce(): Promise { + const sampledAt = new Date().toISOString() + if (collecting) { + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "servers_rest_ping", + sampledAt, + skipped: true, + servers: [], + } + } + collecting = true + const startedAt = Date.now() + const snapshot: ServersRestPingRunSnapshot = { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "servers_rest_ping", + sampledAt, + servers: [], + } + + try { + const targets = serversForRestProbe() + const rows = await Promise.all( + targets.map(async (srv) => { + const name = srv.name || srv.host + const t0 = performance.now() + try { + const client = MikrotikClient.fromServer(srv) + await client.getIdentity() + const latencyMs = Math.round(performance.now() - t0) + return { + serverId: srv.id, + name, + host: srv.host, + ok: true as const, + latencyMs, + } + } catch (err) { + return { + serverId: srv.id, + name, + host: srv.host, + ok: false as const, + latencyMs: null as number | null, + error: err instanceof Error ? err.message : String(err), + } + } + }), + ) + snapshot.servers = rows.sort((a, b) => a.serverId - b.serverId) + + const lastByServer = loadLastRestSampleByServer() + const resourceDown = loadLatestResourceDownServerIds() + + db.transaction((tx) => { + for (const row of snapshot.servers) { + const sid = row.serverId + const last = lastByServer.get(sid) + const errText = row.ok ? null : (row.error ?? null) + + const insertOne = (ok: boolean, latencyMs: number | null, error: string | null) => { + tx.insert(serversRestPingSamples).values({ + serverId: sid, + sampledAt, + ok, + latencyMs, + error, + }).run() + } + + const lat = row.latencyMs ?? null + + if (row.ok) { + const downRes = resourceDown.has(sid) + if (last === undefined && downRes) { + insertOne(false, null, null) + } + insertOne(true, lat, null) + if (last?.ok === false || (last === undefined && downRes)) { + insertOne(true, lat, null) + } + } else { + insertOne(false, null, errText) + } + } + }) + + sqliteDatabase + .prepare(`DELETE FROM servers_rest_ping_samples WHERE sampled_at < datetime('now', '-30 days')`) + .run() + + db.update(serversApiPingSettings) + .set({ + lastCollectedAt: sampledAt, + lastDurationMs: Date.now() - startedAt, + lastError: "", + updatedAt: sampledAt, + }) + .where(eq(serversApiPingSettings.id, 1)) + .run() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + snapshot.fatalError = msg + db.update(serversApiPingSettings) + .set({ + lastCollectedAt: sampledAt, + lastDurationMs: Date.now() - startedAt, + lastError: msg, + updatedAt: sampledAt, + }) + .where(eq(serversApiPingSettings.id, 1)) + .run() + } finally { + collecting = false + } + return snapshot +} diff --git a/backend/src/services/traffic-collector.ts b/backend/src/services/traffic-collector.ts index 0b2bdd1..8f39fc1 100644 --- a/backend/src/services/traffic-collector.ts +++ b/backend/src/services/traffic-collector.ts @@ -1,6 +1,8 @@ import { and, asc, eq, gte, lt } from "drizzle-orm" import { db } from "../db/index.js" import { servers, trafficSamples, trafficSettings } from "../db/schema.js" +import type { TrafficRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" import { MikrotikClient } from "./mikrotik.js" interface RosIfaceTraffic { @@ -19,13 +21,7 @@ export interface TrafficCollectorState { lastError: string | null } -let timer: ReturnType | null = null let collecting = false -const state: TrafficCollectorState = { - running: false, - lastRunAt: null, - lastError: null, -} function toNum(raw: unknown): number { const n = Number.parseFloat(String(raw ?? "0")) @@ -54,36 +50,74 @@ function cleanupOldSamples(retentionDays: number) { .run() } -export async function collectTrafficOnce(): Promise { - if (collecting) return +export async function collectTrafficOnce(): Promise { + const sampledAt = new Date().toISOString() + if (collecting) { + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "traffic", + sampledAt, + skipped: true, + servers: [], + } + } collecting = true const startedAt = Date.now() - const now = new Date().toISOString() + const now = sampledAt const settings = getSettingsRow() + const snapshot: TrafficRunSnapshot = { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "traffic", + sampledAt: now, + servers: [], + } try { const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() for (const srv of enabledServers) { + const name = srv.name || srv.host try { const client = MikrotikClient.fromServer(srv) const ifaces = await client.get("/interface") - if (ifaces.length === 0) continue - db.insert(trafficSamples).values( - ifaces.map((i) => ({ - serverId: srv.id, - interfaceName: i.name ?? "unknown", - sampledAt: now, - rxBytes: toNum(i["rx-byte"]), - txBytes: toNum(i["tx-byte"]), - rxBps: toNum(i["rx-bits-per-second"]), - txBps: toNum(i["tx-bits-per-second"]), - running: (i.running ?? "false") === "true", - disabled: (i.disabled ?? "false") === "true", - })), - ).run() - } catch { - // Continue collecting from remaining servers + let sumRx = 0 + let sumTx = 0 + for (const i of ifaces) { + sumRx += toNum(i["rx-bits-per-second"]) / 1_000_000 + sumTx += toNum(i["tx-bits-per-second"]) / 1_000_000 + } + if (ifaces.length > 0) { + db.insert(trafficSamples).values( + ifaces.map((i) => ({ + serverId: srv.id, + interfaceName: i.name ?? "unknown", + sampledAt: now, + rxBytes: toNum(i["rx-byte"]), + txBytes: toNum(i["tx-byte"]), + rxBps: toNum(i["rx-bits-per-second"]), + txBps: toNum(i["tx-bits-per-second"]), + running: (i.running ?? "false") === "true", + disabled: (i.disabled ?? "false") === "true", + })), + ).run() + } + snapshot.servers.push({ + serverId: srv.id, + name, + host: srv.host, + ok: true, + interfaces: ifaces.length, + sumRxMbps: Math.round(sumRx), + sumTxMbps: Math.round(sumTx), + }) + } catch (err) { + snapshot.servers.push({ + serverId: srv.id, + name, + host: srv.host, + ok: false, + error: err instanceof Error ? err.message : String(err), + }) } } @@ -94,45 +128,38 @@ export async function collectTrafficOnce(): Promise { lastError: "", updatedAt: now, }).where(eq(trafficSettings.id, 1)).run() - - state.lastRunAt = now - state.lastError = null } catch (e) { const msg = e instanceof Error ? e.message : String(e) + snapshot.fatalError = msg db.update(trafficSettings).set({ lastCollectedAt: now, lastDurationMs: Date.now() - startedAt, lastError: msg, updatedAt: now, }).where(eq(trafficSettings.id, 1)).run() - state.lastRunAt = now - state.lastError = msg } finally { collecting = false } + return snapshot } -export function stopTrafficCollector() { - if (timer) { - clearInterval(timer) - timer = null - } - state.running = false -} +/** Таймеры трафика ведёт планировщик. */ +export function stopTrafficCollector() {} -export function restartTrafficCollector() { - stopTrafficCollector() - const settings = getSettingsRow() - if (!settings.enabled) return - const intervalMs = Math.max(5, settings.intervalSec) * 1000 - timer = setInterval(() => { - void collectTrafficOnce() - }, intervalMs) - state.running = true -} +export function restartTrafficCollector() {} export function getTrafficCollectorState(): TrafficCollectorState { - return { ...state } + const s = getSettingsRow() + return { + running: Boolean(s.enabled), + lastRunAt: s.lastCollectedAt ?? null, + lastError: s.lastError || null, + } +} + +/** Идёт активный прогон `collectTrafficOnce` (запись в SQLite ещё не завершена). */ +export function isTrafficCollecting(): boolean { + return collecting } export function getTrafficSettings() { @@ -152,7 +179,6 @@ export function updateTrafficSettings(patch: { updatedAt: new Date().toISOString(), } db.update(trafficSettings).set(next).where(eq(trafficSettings.id, 1)).run() - restartTrafficCollector() return getSettingsRow() } @@ -166,4 +192,3 @@ export function readServerSamplesInRange(serverId: number, sinceIso: string) { .orderBy(asc(trafficSamples.sampledAt)) .all() } - diff --git a/backend/src/services/uptime-collector.ts b/backend/src/services/uptime-collector.ts index 59d1426..9a9cd0c 100644 --- a/backend/src/services/uptime-collector.ts +++ b/backend/src/services/uptime-collector.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, gte, lt } from "drizzle-orm" +import { and, asc, eq, gte, inArray, lt, max, or } from "drizzle-orm" import { db } from "../db/index.js" import { servers, @@ -7,31 +7,18 @@ import { uptimeResourceSamples, uptimeSettings, } from "../db/schema.js" +import type { PingRunSnapshot, ResourcesRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" +import { PING_PROBE_WARN_RTT_MS } from "../constants/ping-probe.js" import { MikrotikClient } from "./mikrotik.js" +import { parseRosCpuLoadPercent, parseRosDataSizeBytes, parseRosUptimeToSeconds } from "./ros-metric-parse.js" -let timer: ReturnType | null = null -let collecting = false +let collectingResources = false +let collectingPing = false -function parseDurationToSeconds(raw: string | undefined): number { - if (!raw) return 0 - let total = 0 - for (const m of raw.matchAll(/(\d+)(w|d|h|m(?!s)|s)/g)) { - const n = Number.parseInt(m[1], 10) - if (!Number.isFinite(n)) continue - switch (m[2]) { - case "w": total += n * 604800; break - case "d": total += n * 86400; break - case "h": total += n * 3600; break - case "m": total += n * 60; break - case "s": total += n; break - } - } - return total -} - -function toNum(raw: unknown): number { - const n = Number.parseInt(String(raw ?? "0"), 10) - return Number.isFinite(n) ? Math.max(0, n) : 0 +/** Параллельный прогон сборщиков uptime — чтобы `alert_engine` не читал БД до коммита сэмплов. */ +export function getUptimeCollectorsState(): { resources: boolean; ping: boolean } { + return { resources: collectingResources, ping: collectingPing } } function parsePingTimeMs(raw: string | undefined): number | null { @@ -44,7 +31,6 @@ function parsePingTimeMs(raw: string | undefined): number | null { const sec = s.match(/^(\d+(?:\.\d+)?)\s*s$/) if (sec) return Number.parseFloat(sec[1]) * 1000 - // HH:MM:SS(.sss) from some RouterOS outputs const clock = s.match(/^(\d+):(\d+):(\d+(?:\.\d+)?)$/) if (clock) { const h = Number.parseFloat(clock[1]) @@ -57,16 +43,19 @@ function parsePingTimeMs(raw: string | undefined): number | null { return Number.isFinite(n) ? n : null } -function getSettings() { +export function getSettings() { const row = db.select().from(uptimeSettings).where(eq(uptimeSettings.id, 1)).limit(1).all()[0] if (row) return row const now = new Date().toISOString() db.insert(uptimeSettings).values({ id: 1, enabled: true, - intervalSec: 15, + resourcesEnabled: true, + pingEnabled: true, + speedEnabled: true, + intervalSec: 300, probeIntervalSec: 15, - speedIntervalSec: 60, + speedIntervalSec: 3600, retentionDays: 14, createdAt: now, updatedAt: now, @@ -105,38 +94,101 @@ export function parsePing(results: Array<{ time?: string; status?: string; sent? typeof lossSource === "number" && Number.isFinite(lossSource) ? Math.max(0, Math.min(100, lossSource)) : 100 - const status: "up" | "warn" | "down" = loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > 60 ? "warn" : "up") + const status: "up" | "warn" | "down" = + loss >= 100 ? "down" : (loss > 1 || (avgRtt ?? 0) > PING_PROBE_WARN_RTT_MS ? "warn" : "up") return { avgRtt, loss, status } } -export async function collectUptimeOnce(): Promise { - if (collecting) return - collecting = true +/** Узлы с REST-опросом для uptime: включённые в каталоге или любой Home Router (см. /servers — HR часто «выкл.» при доступе только из LAN). */ +function serversForUptimeRosPoll() { + return db + .select() + .from(servers) + .where(or(eq(servers.enabled, true), eq(servers.type, "home-router"))) + .all() +} + +function lastProbeSampleTimes(): Map { + const rows = db.select({ + probeId: uptimeProbeSamples.probeId, + lastAt: max(uptimeProbeSamples.sampledAt), + }).from(uptimeProbeSamples).groupBy(uptimeProbeSamples.probeId).all() + const m = new Map() + for (const r of rows) { + if (r.lastAt) m.set(r.probeId, String(r.lastAt)) + } + return m +} + +/** Сбор метрик ресурсов (CPU/RAM/…) по всем включённым серверам. */ +export async function collectResourceSamplesOnce(): Promise { + const sampledAt = new Date().toISOString() + if (collectingResources) { + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "uptime_resources", + sampledAt, + skipped: true, + servers: [], + } + } + collectingResources = true const started = Date.now() - const now = new Date().toISOString() + const now = sampledAt const settings = getSettings() + const snapshot: ResourcesRunSnapshot = { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "uptime_resources", + sampledAt: now, + servers: [], + } try { - const enabledServers = db.select().from(servers).where(eq(servers.enabled, true)).all() + const resourceTargets = serversForUptimeRosPoll() - for (const s of enabledServers) { + for (const s of resourceTargets) { + const name = s.name || s.host try { const client = MikrotikClient.fromServer(s) const resource = await client.getResource() + const cpuLoad = parseRosCpuLoadPercent(resource["cpu-load"]) + const freeMem = parseRosDataSizeBytes(resource["free-memory"]) + const totalMem = parseRosDataSizeBytes(resource["total-memory"]) + const freeHdd = parseRosDataSizeBytes(resource["free-hdd-space"]) + const totalHdd = parseRosDataSizeBytes(resource["total-hdd-space"]) + const uptimeSeconds = parseRosUptimeToSeconds(resource["uptime"]) db.insert(uptimeResourceSamples).values({ serverId: s.id, sampledAt: now, status: "online", - cpuLoad: toNum(resource["cpu-load"]), - freeMemory: toNum(resource["free-memory"]), - totalMemory: toNum(resource["total-memory"]), - freeHddSpace: toNum(resource["free-hdd-space"]), - totalHddSpace: toNum(resource["total-hdd-space"]), - uptimeSeconds: parseDurationToSeconds(resource["uptime"]), + cpuLoad, + freeMemory: freeMem, + totalMemory: totalMem, + freeHddSpace: freeHdd, + totalHddSpace: totalHdd, + uptimeSeconds, boardName: String(resource["board-name"] ?? ""), rosVersion: String(resource["version"] ?? ""), }).run() - } catch { + const memUsedMb = totalMem > 0 ? Math.round((totalMem - freeMem) / (1024 * 1024)) : 0 + const memTotalMb = totalMem > 0 ? Math.round(totalMem / (1024 * 1024)) : 0 + snapshot.servers.push({ + serverId: s.id, + name, + host: s.host, + status: "online", + cpuLoadPct: cpuLoad, + memUsedMb, + memTotalMb, + memUsedPct: totalMem > 0 ? Math.round(((totalMem - freeMem) / totalMem) * 100) : undefined, + diskFreeMb: freeHdd > 0 ? Math.round(freeHdd / (1024 * 1024)) : 0, + diskTotalMb: totalHdd > 0 ? Math.round(totalHdd / (1024 * 1024)) : 0, + uptimeSeconds, + boardName: String(resource["board-name"] ?? "") || undefined, + rosVersion: String(resource["version"] ?? "") || undefined, + }) + } catch (err) { + console.warn(`[uptime-collector] resource serverId=${s.id} (${s.host}):`, err instanceof Error ? err.message : err) db.insert(uptimeResourceSamples).values({ serverId: s.id, sampledAt: now, @@ -150,12 +202,180 @@ export async function collectUptimeOnce(): Promise { boardName: "", rosVersion: "", }).run() + snapshot.servers.push({ + serverId: s.id, + name, + host: s.host, + status: "offline", + error: err instanceof Error ? err.message : String(err), + }) } } + cleanup(Math.max(1, settings.retentionDays)) + db.update(uptimeSettings).set({ + lastCollectedAt: now, + lastDurationMs: Date.now() - started, + lastError: "", + updatedAt: now, + }).where(eq(uptimeSettings.id, 1)).run() + } catch (e) { + snapshot.fatalError = e instanceof Error ? e.message : String(e) + db.update(uptimeSettings).set({ + lastCollectedAt: now, + lastDurationMs: Date.now() - started, + lastError: e instanceof Error ? e.message : String(e), + updatedAt: now, + }).where(eq(uptimeSettings.id, 1)).run() + } finally { + collectingResources = false + } + return snapshot +} + +/** Ping-пробы: учитывается interval_sec у пробы или глобальный probe_interval_sec. */ +export async function collectPingProbesOnce(): Promise { + const sampledAt = new Date().toISOString() + if (collectingPing) { + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "uptime_ping", + sampledAt, + skipped: true, + probes: [], + } + } + collectingPing = true + const started = Date.now() + const now = sampledAt + const settings = getSettings() + const defaultProbeSec = Math.max(5, settings.probeIntervalSec ?? settings.intervalSec) + const lastByProbe = lastProbeSampleTimes() + const nowMs = Date.now() + const snapshot: PingRunSnapshot = { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "uptime_ping", + sampledAt: now, + probes: [], + skippedByInterval: 0, + } + + try { + const rosPollServers = serversForUptimeRosPoll() const probes = db.select().from(uptimeProbes).where(eq(uptimeProbes.enabled, true)).orderBy(asc(uptimeProbes.sortOrder)).all() + for (const p of probes) { - const src = enabledServers.find((s) => s.id === p.srcServerId) + const effectiveSec = (p.intervalSec != null && p.intervalSec > 0) ? p.intervalSec : defaultProbeSec + const lastIso = lastByProbe.get(p.id) + if (lastIso) { + const elapsed = nowMs - Date.parse(lastIso) + if (elapsed < effectiveSec * 1000) { + snapshot.skippedByInterval = (snapshot.skippedByInterval ?? 0) + 1 + continue + } + } + + const src = rosPollServers.find((s) => s.id === p.srcServerId) + if (!src) continue + const srcServerName = src.name || src.host + try { + const client = MikrotikClient.fromServer(src) + const results = await client.ping(p.target, 4, p.srcInterface || undefined) + const parsed = parsePing(results) + db.insert(uptimeProbeSamples).values({ + probeId: p.id, + sampledAt: now, + rttMs: parsed.avgRtt, + lossPct: parsed.loss, + status: parsed.status, + }).run() + snapshot.probes.push({ + probeId: p.id, + name: p.name, + target: p.target, + srcServerId: p.srcServerId, + srcServerName, + srcInterface: p.srcInterface || "", + ok: true, + rttMs: parsed.avgRtt, + lossPct: parsed.loss, + status: parsed.status, + }) + } catch (err) { + db.insert(uptimeProbeSamples).values({ + probeId: p.id, + sampledAt: now, + rttMs: null, + lossPct: 100, + status: "down", + }).run() + snapshot.probes.push({ + probeId: p.id, + name: p.name, + target: p.target, + srcServerId: p.srcServerId, + srcServerName, + srcInterface: p.srcInterface || "", + ok: false, + rttMs: null, + lossPct: 100, + status: "down", + error: err instanceof Error ? err.message : String(err), + }) + } + } + + cleanup(Math.max(1, settings.retentionDays)) + db.update(uptimeSettings).set({ + lastCollectedAt: now, + lastDurationMs: Date.now() - started, + lastError: "", + updatedAt: now, + }).where(eq(uptimeSettings.id, 1)).run() + } catch (e) { + snapshot.fatalError = e instanceof Error ? e.message : String(e) + db.update(uptimeSettings).set({ + lastCollectedAt: now, + lastDurationMs: Date.now() - started, + lastError: e instanceof Error ? e.message : String(e), + updatedAt: now, + }).where(eq(uptimeSettings.id, 1)).run() + } finally { + collectingPing = false + } + return snapshot +} + +const MAX_PROBE_IDS_PER_GROUP_COLLECT = 64 + +/** + * Немедленный ping по указанным пробам (без учёта interval_sec) — запись в uptime_probe_samples. + * Используется кнопкой «Обновить» у группы «сервер + назначение» на вкладке проб. + */ +export async function collectPingForProbeIds(probeIds: string[]): Promise<{ polled: number }> { + const uniqueIds = [...new Set(probeIds.filter((id) => typeof id === "string" && id.trim() !== ""))].slice( + 0, + MAX_PROBE_IDS_PER_GROUP_COLLECT, + ) + if (uniqueIds.length === 0) return { polled: 0 } + + if (collectingPing) { + const e = new Error("Сбор ping уже выполняется, подождите") + ;(e as Error & { statusCode?: number }).statusCode = 409 + throw e + } + + collectingPing = true + const now = new Date().toISOString() + const settings = getSettings() + let polled = 0 + + try { + const rosPollServers = serversForUptimeRosPoll() + const rows = db.select().from(uptimeProbes).where(inArray(uptimeProbes.id, uniqueIds)).all() + + for (const p of rows) { + const src = rosPollServers.find((s) => s.id === p.srcServerId) if (!src) continue try { const client = MikrotikClient.fromServer(src) @@ -177,64 +397,74 @@ export async function collectUptimeOnce(): Promise { status: "down", }).run() } + polled += 1 } cleanup(Math.max(1, settings.retentionDays)) - db.update(uptimeSettings).set({ - lastCollectedAt: now, - lastDurationMs: Date.now() - started, - lastError: "", - updatedAt: now, - }).where(eq(uptimeSettings.id, 1)).run() - } catch (e) { - db.update(uptimeSettings).set({ - lastCollectedAt: now, - lastDurationMs: Date.now() - started, - lastError: e instanceof Error ? e.message : String(e), - updatedAt: now, - }).where(eq(uptimeSettings.id, 1)).run() + return { polled } } finally { - collecting = false + collectingPing = false } } -export function restartUptimeCollector() { - const s = getSettings() - if (timer) clearInterval(timer) - timer = null - if (!s.enabled) return - const intervalMs = Math.max(5, s.probeIntervalSec || s.intervalSec) * 1000 - timer = setInterval(() => { void collectUptimeOnce() }, intervalMs) +/** Совместимость: один проход ресурсы + ping (для ручного вызова без планировщика). */ +export async function collectUptimeOnce(): Promise { + await collectResourceSamplesOnce() + await collectPingProbesOnce() } -export function stopUptimeCollector() { - if (timer) clearInterval(timer) - timer = null -} +/** no-op: таймеры управляются планировщиком */ +export function restartUptimeCollector() {} + +export function stopUptimeCollector() {} export function readUptimeSettings() { return getSettings() } export function updateUptimeSettings(patch: { + /** Устаревший: если передан без per-job флагов — выставляет все три job-флага. */ enabled?: boolean + resourcesEnabled?: boolean + pingEnabled?: boolean + speedEnabled?: boolean + /** Интервал сбора метрик ресурсов (сек). */ intervalSec?: number + /** Интервал тика ping и значение по умолчанию для проб без своего interval_sec. */ probeIntervalSec?: number speedIntervalSec?: number retentionDays?: number }) { const prev = getSettings() - const nextProbeInterval = patch.probeIntervalSec ?? patch.intervalSec ?? prev.probeIntervalSec ?? prev.intervalSec + const anyJobFlag = + patch.resourcesEnabled !== undefined + || patch.pingEnabled !== undefined + || patch.speedEnabled !== undefined + + let resourcesEnabled = patch.resourcesEnabled ?? prev.resourcesEnabled ?? prev.enabled + let pingEnabled = patch.pingEnabled ?? prev.pingEnabled ?? prev.enabled + let speedEnabled = patch.speedEnabled ?? prev.speedEnabled ?? prev.enabled + + if (patch.enabled !== undefined && !anyJobFlag) { + resourcesEnabled = patch.enabled + pingEnabled = patch.enabled + speedEnabled = patch.enabled + } + + const masterEnabled = Boolean(resourcesEnabled || pingEnabled || speedEnabled) + const next = { - enabled: patch.enabled ?? prev.enabled, - intervalSec: nextProbeInterval, - probeIntervalSec: nextProbeInterval, + enabled: masterEnabled, + resourcesEnabled, + pingEnabled, + speedEnabled, + intervalSec: patch.intervalSec ?? prev.intervalSec, + probeIntervalSec: patch.probeIntervalSec ?? prev.probeIntervalSec, speedIntervalSec: patch.speedIntervalSec ?? prev.speedIntervalSec ?? 60, retentionDays: patch.retentionDays ?? prev.retentionDays, updatedAt: new Date().toISOString(), } db.update(uptimeSettings).set(next).where(eq(uptimeSettings.id, 1)).run() - restartUptimeCollector() return readUptimeSettings() } @@ -260,20 +490,20 @@ export function replaceProbes(rows: Array<{ probeFilter: string enabled: boolean showOnDashboard?: boolean + intervalSec?: number }>) { const now = new Date().toISOString() const existing = db.select({ id: uptimeProbes.id }).from(uptimeProbes).all() const nextIds = new Set(rows.map((r) => r.id)) - // Remove only probes that user actually deleted (their samples are removed by FK cascade). for (const p of existing) { if (nextIds.has(p.id)) continue db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)).run() } - // Upsert rows in-place to preserve sample history for unchanged probe ids. for (let i = 0; i < rows.length; i += 1) { const r = rows[i] + const intervalSec = r.intervalSec != null && r.intervalSec > 0 ? Math.max(5, Math.floor(r.intervalSec)) : 0 const patch = { srcServerId: r.srcServerId, srcInterface: r.srcInterface || "", @@ -282,6 +512,7 @@ export function replaceProbes(rows: Array<{ probeFilter: r.probeFilter || "—", enabled: r.enabled, showOnDashboard: r.showOnDashboard ?? false, + intervalSec, sortOrder: i, updatedAt: now, } @@ -311,4 +542,3 @@ export function readResourceSamplesSince(sinceIso: string, serverId: number) { .orderBy(asc(uptimeResourceSamples.sampledAt)) .all() } - diff --git a/backend/src/services/uptime-speed-service.ts b/backend/src/services/uptime-speed-service.ts new file mode 100644 index 0000000..23bcc79 --- /dev/null +++ b/backend/src/services/uptime-speed-service.ts @@ -0,0 +1,350 @@ +import { asc, eq } from "drizzle-orm" +import { db } from "../db/index.js" +import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js" +import type { SpeedRunSnapshot, SpeedScheduledRunSnapshot } from "../types/scheduler-run-snapshot.js" +import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" +import type { RosIpAddress } from "../types/server.js" +import { withBtestNodeLocks } from "./btest-node-locks.js" +import { MikrotikClient } from "./mikrotik.js" +import { parsePing } from "./uptime-collector.js" + +function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string { + const name = iface.trim() + const candidates = rows.filter((a) => { + if (String(a.interface ?? "").trim() !== name) return false + if (String(a.disabled ?? "false").toLowerCase() === "true") return false + if (String(a.invalid ?? "false").toLowerCase() === "true") return false + const addr = String(a.address ?? "").trim() + return addr.includes("/") + }) + candidates.sort((a, b) => { + const ad = String(a.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0 + const bd = String(b.dynamic ?? "false").toLowerCase() === "true" ? 1 : 0 + return ad - bd + }) + const raw = candidates[0]?.address + return raw ? String(raw).split("/")[0]?.trim() ?? "" : "" +} + +function parseRateToMbps(raw: unknown): number { + const txt = String(raw ?? "").trim().toLowerCase() + if (!txt) return 0 + const m = txt.match(/^(\d+(?:\.\d+)?)\s*(g|m|k)?(?:bps|bit\/s|bits\/s)?$/) + if (m) { + const n = Number.parseFloat(m[1]) + const u = m[2] ?? "" + if (!Number.isFinite(n)) return 0 + if (u === "g") return n * 1000 + if (u === "m") return n + if (u === "k") return n / 1000 + return n / 1_000_000 + } + const n = Number.parseFloat(txt.replace(/[^\d.]/g, "")) + if (!Number.isFinite(n)) return 0 + return n > 10000 ? n / 1_000_000 : n +} + +export interface SpeedTestRunParams { + runId?: string + probeId?: string + srcServerId: number + dstServerId: number + srcInterface?: string + dstInterface?: string + protocol?: "tcp" | "udp" + direction?: "transmit" | "receive" | "both" + durationSec?: number +} + +export interface SpeedTestRunResult { + runId: string + srcInterface: string | null + dstInterface: string | null + address: string + srcAddress: string + dstAddress: string + srcInterfaceAddress: string | null + dstInterfaceAddress: string | null + protocol: "tcp" | "udp" + direction: "transmit" | "receive" | "both" + durationSec: number + txAvgMbps: number + rxAvgMbps: number + txSeries: number[] + rxSeries: number[] + raw: Array> + afterBtPing: { + rttMs: number | null + lossPct: number | null + error: string | null + } +} + +function humanizeSpeedError(raw: string): string { + return /timed out|socket hang up|ECONNRESET/i.test(raw) + ? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов" + : raw +} + +/** Полный цикл bandwidth-test + after-ping + запись в БД. Вызывать внутри withBtestNodeLocks при необходимости — здесь lock уже снаружи. */ +export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams): Promise { + const srcId = params.srcServerId + const dstId = params.dstServerId + const src = db.select().from(servers).where(eq(servers.id, srcId)).limit(1).all()[0] + const dst = db.select().from(servers).where(eq(servers.id, dstId)).limit(1).all()[0] + if (!src || !dst) throw new Error("Server not found") + + const protocol = params.protocol === "udp" ? "udp" : "tcp" + const direction = params.direction === "transmit" || params.direction === "receive" ? params.direction : "both" + const durationSec = Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10) + const srcInterface = String(params.srcInterface ?? "").trim() + const dstInterface = String(params.dstInterface ?? "").trim() + const srcClient = MikrotikClient.fromServer(src) + const dstAddressRows = dstInterface + ? await MikrotikClient.fromServer(dst).getIpAddresses().catch(() => []) + : [] + const srcAddressRows = srcInterface + ? await srcClient.getIpAddresses().catch(() => []) + : [] + const dstAddress = dstInterface ? firstUsableIpOnInterface(dstAddressRows, dstInterface) : dst.host + if (dstInterface && !dstAddress) { + throw new Error(`На интерфейсе назначения '${dstInterface}' нет активного IP (проверьте /ip/address — не disabled).`) + } + const initialAddress = dstAddress || dst.host + const srcAddress = srcInterface ? firstUsableIpOnInterface(srcAddressRows, srcInterface) : src.host + const srcInterfaceAddress = srcInterface ? (srcAddress || null) : null + const dstInterfaceAddress = dstInterface ? (dstAddress || null) : null + + const rows = await srcClient.bandwidthTest({ + address: initialAddress, + user: dst.username, + password: dst.password, + protocol, + direction, + durationSec, + }) + const txSeries = rows + .map((r) => parseRateToMbps(r["tx-current"] ?? r["tx-10-second-average"] ?? r["tx-total-average"])) + .filter((v) => Number.isFinite(v) && v >= 0) + const rxSeries = rows + .map((r) => parseRateToMbps(r["rx-current"] ?? r["rx-10-second-average"] ?? r["rx-total-average"])) + .filter((v) => Number.isFinite(v) && v >= 0) + const last = rows[rows.length - 1] ?? {} + const txAvg = parseRateToMbps(last["tx-total-average"] ?? last["tx-10-second-average"]) + const rxAvg = parseRateToMbps(last["rx-total-average"] ?? last["rx-10-second-average"]) + const txFinal = txAvg || (txSeries.length ? txSeries.reduce((a, b) => a + b, 0) / txSeries.length : 0) + const rxFinal = rxAvg || (rxSeries.length ? rxSeries.reduce((a, b) => a + b, 0) / rxSeries.length : 0) + + await new Promise((resolve) => setTimeout(resolve, 3000)) + + const pingNow = new Date().toISOString() + let afterBtPing: SpeedTestRunResult["afterBtPing"] = { rttMs: null, lossPct: null, error: null } + try { + const pingRows = await srcClient.ping(initialAddress, 4, srcInterface || undefined, { interval: "1s" }) + const pr = parsePing(pingRows as Parameters[0]) + afterBtPing = { rttMs: pr.avgRtt, lossPct: pr.loss, error: null } + } catch (pe) { + afterBtPing = { + rttMs: null, + lossPct: null, + error: pe instanceof Error ? pe.message : String(pe), + } + } + + const probeId = String(params.probeId ?? "").trim() + const runId = String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + if (probeId) { + db.update(uptimeSpeedProbes).set({ + lastRunAt: pingNow, + lastTxAvgMbps: txFinal, + lastRxAvgMbps: rxFinal, + lastStatus: "done", + lastError: "", + lastPingRttMs: afterBtPing.rttMs, + lastPingLossPct: afterBtPing.lossPct, + lastPingAt: pingNow, + lastPingError: afterBtPing.error ?? "", + updatedAt: pingNow, + }).where(eq(uptimeSpeedProbes.id, probeId)).run() + } + db.insert(uptimeSpeedTestRuns).values({ + id: runId, + probeId: probeId || null, + srcServerId: srcId, + dstServerId: dstId, + srcInterface: srcInterface || "", + dstInterface: dstInterface || "", + srcAddress: srcAddress || src.host, + dstAddress: initialAddress, + srcInterfaceAddress, + dstInterfaceAddress, + protocol, + direction, + durationSec, + txAvgMbps: txFinal, + rxAvgMbps: rxFinal, + pingRttMs: afterBtPing.rttMs, + pingLossPct: afterBtPing.lossPct, + pingError: afterBtPing.error ?? null, + status: "done", + error: null, + createdAt: pingNow, + }).run() + + return { + runId, + srcInterface: srcInterface || null, + dstInterface: dstInterface || null, + address: initialAddress, + srcAddress: srcAddress || src.host, + dstAddress: initialAddress, + srcInterfaceAddress, + dstInterfaceAddress, + protocol, + direction, + durationSec, + txAvgMbps: txFinal, + rxAvgMbps: rxFinal, + txSeries, + rxSeries, + raw: rows, + afterBtPing, + } +} + +export async function runBandwidthSpeedTest(params: SpeedTestRunParams): Promise { + const srcId = params.srcServerId + const dstId = params.dstServerId + if (srcId === dstId) throw new Error("Source and destination must be different") + return withBtestNodeLocks([srcId, dstId], () => executeBandwidthSpeedTestInner(params)) +} + +export function recordSpeedTestFailure(params: { + probeId?: string + runId?: string + srcServerId: number + dstServerId: number + srcInterface?: string + dstInterface?: string + protocol?: "tcp" | "udp" + direction?: "transmit" | "receive" | "both" + durationSec?: number + message: string +}) { + const probeId = String(params.probeId ?? "").trim() + const message = humanizeSpeedError(params.message) + if (probeId) { + db.update(uptimeSpeedProbes).set({ + lastRunAt: new Date().toISOString(), + lastStatus: "error", + lastError: message, + updatedAt: new Date().toISOString(), + }).where(eq(uptimeSpeedProbes.id, probeId)).run() + } + db.insert(uptimeSpeedTestRuns).values({ + id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + probeId: probeId || null, + srcServerId: params.srcServerId, + dstServerId: params.dstServerId, + srcInterface: String(params.srcInterface ?? "").trim(), + dstInterface: String(params.dstInterface ?? "").trim(), + srcAddress: null, + dstAddress: null, + srcInterfaceAddress: null, + dstInterfaceAddress: null, + protocol: params.protocol === "udp" ? "udp" : "tcp", + direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both", + durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10), + txAvgMbps: null, + rxAvgMbps: null, + pingRttMs: null, + pingLossPct: null, + pingError: null, + status: "error", + error: message, + createdAt: new Date().toISOString(), + }).run() +} + +function serverDisplayName(id: number): string { + const s = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] + return s ? (s.name || s.host) : `#${id}` +} + +/** Фоновый прогон всех включённых speed-проб (по очереди). */ +export async function runScheduledSpeedProbesOnce(): Promise { + const sampledAt = new Date().toISOString() + const runs: SpeedRunSnapshot[] = [] + const rows = db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)).all() + const enabled = rows.filter((r) => r.enabled !== false && r.srcServerId !== r.dstServerId) + const protocol = (p: (typeof enabled)[number]) => (p.protocol === "udp" ? "udp" : "tcp") + const direction = (p: (typeof enabled)[number]) => + p.direction === "transmit" || p.direction === "receive" ? p.direction : "both" + const durationSec = (p: (typeof enabled)[number]) => Math.max(3, p.durationSec || 10) + + for (const p of enabled) { + try { + const r = await runBandwidthSpeedTest({ + probeId: p.id, + srcServerId: p.srcServerId, + dstServerId: p.dstServerId, + srcInterface: p.srcInterface || undefined, + dstInterface: p.dstInterface || undefined, + protocol: protocol(p) as "tcp" | "udp", + direction: direction(p) as "transmit" | "receive" | "both", + durationSec: durationSec(p), + }) + runs.push({ + probeId: p.id, + srcServerId: p.srcServerId, + srcServerName: serverDisplayName(p.srcServerId), + dstServerId: p.dstServerId, + dstServerName: serverDisplayName(p.dstServerId), + srcInterface: r.srcInterface ?? "", + dstInterface: r.dstInterface ?? "", + protocol: r.protocol, + direction: r.direction, + durationSec: r.durationSec, + ok: true, + txAvgMbps: r.txAvgMbps, + rxAvgMbps: r.rxAvgMbps, + pingRttMs: r.afterBtPing.rttMs, + pingLossPct: r.afterBtPing.lossPct, + pingError: r.afterBtPing.error, + }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + recordSpeedTestFailure({ + probeId: p.id, + srcServerId: p.srcServerId, + dstServerId: p.dstServerId, + srcInterface: p.srcInterface, + dstInterface: p.dstInterface, + protocol: protocol(p), + direction: direction(p), + durationSec: p.durationSec, + message: msg, + }) + runs.push({ + probeId: p.id, + srcServerId: p.srcServerId, + srcServerName: serverDisplayName(p.srcServerId), + dstServerId: p.dstServerId, + dstServerName: serverDisplayName(p.dstServerId), + srcInterface: p.srcInterface || "", + dstInterface: p.dstInterface || "", + protocol: protocol(p), + direction: direction(p), + durationSec: durationSec(p), + ok: false, + error: msg, + }) + } + } + return { + v: SCHEDULER_RUN_SNAPSHOT_VERSION, + job: "uptime_speed", + sampledAt, + runs, + } +} diff --git a/backend/src/types/scheduler-run-snapshot.ts b/backend/src/types/scheduler-run-snapshot.ts new file mode 100644 index 0000000..bdfdb9e --- /dev/null +++ b/backend/src/types/scheduler-run-snapshot.ts @@ -0,0 +1,193 @@ +/** + * Снимок результатов одного прогона планировщика (JSON в `scheduler_runs.result_json`). + * Версия `v` для совместимости при расширении схемы. + */ + +export const SCHEDULER_RUN_SNAPSHOT_VERSION = 1 as const + +export interface TrafficServerSnapshot { + serverId: number + name: string + host: string + ok: boolean + error?: string + /** Сколько интерфейсов записано в сэмплы */ + interfaces?: number + /** Сумма RX по интерфейсам, Мбит/с (округл.) */ + sumRxMbps?: number + /** Сумма TX по интерфейсам, Мбит/с (округл.) */ + sumTxMbps?: number +} + +export interface TrafficRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "traffic" + sampledAt: string + /** Прогон пропущен — предыдущий сбор ещё выполняется */ + skipped?: boolean + /** Критическая ошибка до/вне цикла по серверам */ + fatalError?: string + servers: TrafficServerSnapshot[] +} + +export interface ResourceServerSnapshot { + serverId: number + name: string + host: string + status: "online" | "offline" + error?: string + cpuLoadPct?: number + memUsedMb?: number + memTotalMb?: number + memUsedPct?: number + diskFreeMb?: number + diskTotalMb?: number + uptimeSeconds?: number + boardName?: string + rosVersion?: string +} + +export interface ResourcesRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "uptime_resources" + sampledAt: string + skipped?: boolean + fatalError?: string + servers: ResourceServerSnapshot[] +} + +export interface PingProbeSnapshot { + probeId: string + name: string + target: string + srcServerId: number + srcServerName: string + srcInterface: string + ok: boolean + rttMs: number | null + lossPct: number + status: "up" | "warn" | "down" + error?: string +} + +export interface PingRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "uptime_ping" + sampledAt: string + skipped?: boolean + fatalError?: string + /** Пробы, для которых в этом тике записан сэмпл (не пропущены по interval) */ + probes: PingProbeSnapshot[] + /** Сколько проб пропущено из‑за interval_sec */ + skippedByInterval?: number +} + +export interface SpeedRunSnapshot { + probeId: string + srcServerId: number + srcServerName: string + dstServerId: number + dstServerName: string + srcInterface: string + dstInterface: string + protocol: string + direction: string + durationSec: number + ok: boolean + txAvgMbps?: number | null + rxAvgMbps?: number | null + pingRttMs?: number | null + pingLossPct?: number | null + pingError?: string | null + error?: string +} + +export interface SpeedScheduledRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "uptime_speed" + sampledAt: string + runs: SpeedRunSnapshot[] +} + +/** Узел каталога: успешный GET `/system/identity` (REST) и время ответа. */ +export interface ServerRestPingSnapshot { + serverId: number + name: string + host: string + ok: boolean + /** RTT успешного REST-запроса, мс */ + latencyMs?: number | null + error?: string +} + +export interface ServersRestPingRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "servers_rest_ping" + sampledAt: string + skipped?: boolean + fatalError?: string + servers: ServerRestPingSnapshot[] +} + +/** Одна строка диагностики прогона движка (для `scheduler_runs.result_json`). */ +export type AlertEngineRuleDiagSnapshot = { + ruleId: string + inGroup: boolean + evalHit: boolean + hitTransition?: "problem" | "recovery" | "neutral" + hitMessage?: string + stabilityOk: boolean + cooldownOk: boolean + telegramOk: boolean + blocked?: + | "no_hit" + | "stability" + | "cooldown" + | "no_telegram" + | "dedupe_positive" + | "in_group" +} + +export interface AlertEngineRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "alert_engine" + sampledAt: string + rulesChecked: number + standaloneFires: number + groupFires: number + skippedNoTelegram: boolean + errors?: string[] + /** Почему правило не дало отдельную отправку в этом тике (если массив пуст — движок без правил). */ + ruleDiag?: AlertEngineRuleDiagSnapshot[] +} + +/** Снимок джобы `gre_bgp`: запись GRE/BGP в SQLite для алерт-движка. */ +export interface GreBgpSnapshotRunSnapshot { + v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION + job: "gre_bgp" + sampledAt: string + skipped?: boolean + fatalError?: string + greWritten: number + bgpWritten: number + /** Текущее состояние GRE на момент прогона (для alert_engine). */ + greTunnels?: Array<{ + targetLabel: string + status: "up" | "down" | "degraded" + }> + /** Текущее состояние BGP-пиров на момент прогона (для alert_engine). */ + bgpPeers?: Array<{ + key: string + state: string + }> + errors?: string[] +} + +export type SchedulerRunSnapshot = + | TrafficRunSnapshot + | ResourcesRunSnapshot + | PingRunSnapshot + | SpeedScheduledRunSnapshot + | ServersRestPingRunSnapshot + | GreBgpSnapshotRunSnapshot + | AlertEngineRunSnapshot diff --git a/backend/src/utils/server-id.ts b/backend/src/utils/server-id.ts new file mode 100644 index 0000000..6d73aca --- /dev/null +++ b/backend/src/utils/server-id.ts @@ -0,0 +1,10 @@ +/** + * Парсит id сервера из БД (положительное целое). + * Не извлекает цифры из строк вроде «srv1» — только целая строка из цифр. + */ +export function parseDbServerId(raw: unknown): number | null { + const s = String(raw ?? "").trim() + if (!/^\d+$/.test(s)) return null + const n = Number.parseInt(s, 10) + return Number.isFinite(n) && n >= 1 ? n : null +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 8712c59..947e739 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -30,6 +30,7 @@ import { GitMergeIcon, TerminalIcon, SettingsIcon, + DatabaseIcon, CableIcon, ScanLineIcon, BellIcon, @@ -39,6 +40,13 @@ import { BoxIcon, BadgeCheckIcon, } from "lucide-react" +import { useDataSource } from "@/lib/data-source" +import { useEvoBGP } from "@/lib/evobgp-context" +import { + formatSidebarBadgeCount, + mockSidebarBadgesByUrl, + type SidebarCountsDto, +} from "@/lib/sidebar-badges" // ─── Command palette trigger button ────────────────────────────────────────── @@ -58,69 +66,154 @@ function CommandPaletteButton() { ) } -// ───────────────────────────────────────────────────────────────────────────── +type NavItemBase = { title: string; url: string; icon: React.ReactNode } -const navGroups: NavGroup[] = [ +const navStructure: { label: string; items: NavItemBase[] }[] = [ { label: "Обзор", items: [ - { title: "Дашборд", url: "/dashboard", icon: }, - { title: "Трафик", url: "/traffic", icon: }, - { title: "Карта сети", url: "/network-map", icon: }, - { title: "Мониторинг", url: "/uptime", icon: , badge: "8" }, + { title: "Дашборд", url: "/dashboard", icon: }, + { title: "Трафик", url: "/traffic", icon: }, + { title: "Карта сети", url: "/network-map", icon: }, + { title: "Мониторинг", url: "/uptime", icon: }, ], }, { label: "Данные", items: [ - { title: "Домены", url: "/domains", icon: , badge: "1.8к" }, - { title: "IP-диапазоны", url: "/ip-ranges", icon: , badge: "4.2к" }, - { title: "ASN", url: "/asns", icon: }, - { title: "Communities", url: "/communities", icon: }, + { title: "Домены", url: "/domains", icon: }, + { title: "IP-диапазоны", url: "/ip-ranges", icon: }, + { title: "ASN", url: "/asns", icon: }, + { title: "Communities", url: "/communities", icon: }, ], }, { label: "Управление", items: [ - { title: "Серверы", url: "/servers", icon: , badge: "9" }, - { title: "Фильтры", url: "/filters", icon: , badge: "6" }, + { title: "Серверы", url: "/servers", icon: }, + { title: "Фильтры", url: "/filters", icon: }, { title: "Рекурсивные маршруты", url: "/recursive-routes", icon: }, - { title: "Firewall", url: "/firewall", icon: }, - { title: "WireGuard", url: "/wireguard", icon: , badge: "4" }, - { title: "GRE-туннели", url: "/gre", icon: , badge: "6" }, - { title: "VXLAN", url: "/vxlan", icon: }, - { title: "Контейнеры", url: "/containers", icon: , badge: "5" }, - { title: "Сертификаты", url: "/certificates", icon: , badge: "2" }, - { title: "Бэкапы", url: "/backups", icon: }, + { title: "Firewall", url: "/firewall", icon: }, + { title: "WireGuard", url: "/wireguard", icon: }, + { title: "GRE-туннели", url: "/gre", icon: }, + { title: "VXLAN", url: "/vxlan", icon: }, + { title: "Контейнеры", url: "/containers", icon: }, + { title: "Сертификаты", url: "/certificates", icon: }, + { title: "Бэкапы", url: "/backups", icon: }, ], }, { label: "Инструменты", items: [ { title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: }, - { title: "OSPF", url: "/ospf", icon: }, - { title: "BGP", url: "/bgp", icon: , badge: "12" }, - { title: "Диагностика", url: "/probes", icon: }, - { title: "Терминал", url: "/terminal", icon: }, + { title: "OSPF", url: "/ospf", icon: }, + { title: "BGP", url: "/bgp", icon: }, + { title: "Диагностика", url: "/probes", icon: }, + { title: "Терминал", url: "/terminal", icon: }, ], }, { label: "Система", items: [ - { title: "Оповещения", url: "/alerts", icon: }, - { title: "Настройки", url: "/settings", icon: }, + { title: "Оповещения", url: "/alerts", icon: }, + { title: "Сбор данных", url: "/data-collection", icon: }, + { title: "Настройки", url: "/settings", icon: }, ], }, ] +type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number } + export function AppSidebar({ ...props }: React.ComponentProps) { const { resolvedTheme, setTheme } = useTheme() + const { mode, backendUrl, backendStatus } = useDataSource() + const evo = useEvoBGP() const [mounted, setMounted] = React.useState(false) + const [liveCounts, setLiveCounts] = React.useState(null) + + const mockBadges = React.useMemo(() => mockSidebarBadgesByUrl(), []) + React.useEffect(() => { queueMicrotask(() => { setMounted(true) }) }, []) + + React.useEffect(() => { + if (mode !== "live" || backendStatus !== true) { + setLiveCounts(null) + return + } + let cancelled = false + const load = async () => { + try { + const base = backendUrl.replace(/\/$/, "") + const [cRes, gRes] = await Promise.all([ + fetch(`${base}/api/sidebar-counts`), + fetch(`${base}/api/filters/gre-tunnels`), + ]) + if (cancelled) return + if (!cRes.ok) { + setLiveCounts(null) + return + } + const cJson = (await cRes.json()) as SidebarCountsDto + let greN = 0 + if (gRes.ok) { + const gJson = (await gRes.json()) as { tunnels?: unknown[] } + greN = (gJson.tunnels ?? []).length + } + setLiveCounts({ ...cJson, greTunnels: greN }) + } catch { + if (!cancelled) setLiveCounts(null) + } + } + void load() + const id = window.setInterval(load, 120_000) + return () => { + cancelled = true + window.clearInterval(id) + } + }, [mode, backendStatus, backendUrl]) + + const navGroups = React.useMemo((): NavGroup[] => { + function badgeFor(url: string): string | undefined { + if (mode !== "live") return mockBadges[url] + if (backendStatus !== true) return undefined + + if (url === "/domains" || url === "/ip-ranges" || url === "/asns") { + if (!evo.enabled) return mockBadges[url] + if (!evo.snapshot) return undefined + if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length) + if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length) + return formatSidebarBadgeCount(evo.snapshot.asns.length) + } + + if (!liveCounts) return undefined + + if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers) + if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules) + if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems) + if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0) + + if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") { + return undefined + } + + return undefined + } + + return navStructure.map((g) => ({ + label: g.label, + items: g.items.map((it) => ({ + title: it.title, + url: it.url, + icon: it.icon, + badge: badgeFor(it.url), + })), + })) + }, [mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot]) + const isDark = (resolvedTheme ?? "dark") === "dark" return ( @@ -135,7 +228,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) { v3.4.1
- {/* Command palette trigger */} @@ -143,16 +235,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
- {/* Avatar */}
АК
- {/* Name / email */}
а.коротаев admin@routerlists.io
- {/* Theme toggle — только после mount, иначе SSR и клиент расходятся (next-themes). */} {mounted ? (