"use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useDataSource } from "@/lib/data-source" import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" import { OpsPanel } from "@/components/ops-panel" 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter, } from "@/components/ui/sheet" import { 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" import { requestJson } from "@/shared/api/http-client" import { toast } from "sonner" // ─── types ──────────────────────────────────────────────────────────────────── 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 /** Объекты правила (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 severity: AlertSeverity message: string time: string sent: boolean } 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 { return requestJson(backendUrl, path, init) } } 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 label: string iconClass: string bg: string }> = { "gre-tunnel": { Icon: CableIcon, label: "GRE-туннель", iconClass: "text-violet-500", bg: "bg-violet-500/10" }, "bgp-peer": { Icon: NetworkIcon, label: "BGP-сосед", iconClass: "text-blue-500", bg: "bg-blue-500/10" }, "bgp-prefix": { Icon: RouteIcon, label: "BGP-префикс", iconClass: "text-sky-500", bg: "bg-sky-500/10" }, "gre-client": { Icon: UserIcon, label: "GRE-клиент", iconClass: "text-purple-500", bg: "bg-purple-500/10" }, "server": { Icon: ServerIcon, label: "Сервер", iconClass: "text-slate-500", bg: "bg-slate-500/10" }, "rtt": { Icon: TimerIcon, label: "Задержка (RTT)", iconClass: "text-amber-500", bg: "bg-amber-500/10" }, "loss": { Icon: WifiOffIcon, label: "Потери пакетов", iconClass: "text-orange-500", bg: "bg-orange-500/10" }, "traffic": { Icon: TrendingDownIcon, label: "Низкий трафик", iconClass: "text-rose-500", bg: "bg-rose-500/10" }, } const SEVERITY_META: Record = { critical: { label: "Критическое", dot: "bg-red-500", badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400", chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400", }, warning: { label: "Предупреждение", dot: "bg-amber-400", badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400", chip: "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400", }, info: { label: "Информационное", dot: "bg-blue-500", badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400", chip: "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400", }, } const TYPE_TARGETS: Record = { "gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"], "bgp-peer": ["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"], "bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"], "gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"], "server": ["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"], "rtt": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"], "loss": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"], "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": ["разорвал сессию", "восстановил сессию"], "bgp-prefix": ["был отозван", "был получен"], "gre-client": ["отключился", "подключился"], "server": ["перешёл в offline", "перешёл в degraded", "восстановился"], "rtt": ["> порога", "< порога"], "loss": ["> порога"], "traffic": ["RX < порога", "TX < порога"], } const THRESHOLD_UNIT: Partial> = { rtt: "мс", loss: "%", traffic: "Мбит/с", } 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", 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 }, { id: "h3", ruleName: "BGP-префикс отозван core-01", severity: "info", message: "Префикс 185.13.0.0/22 отозван на mt-msk-core-01", time: "6ч назад", sent: true }, { id: "h4", ruleName: "BGP-сосед core-01 down", severity: "critical", message: "BGP-сессия с mt-msk-core-01 разорвана", time: "1д назад", sent: true }, { 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: "", chatId: "", messageThreadId: "", connected: false, } // ─── small helpers ──────────────────────────────────────────────────────────── function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) { return

{children}

} function FieldHint({ children }: { children: React.ReactNode }) { return

{children}

} function SeverityDot({ severity }: { severity: AlertSeverity }) { return } function SeverityBadge({ severity }: { severity: AlertSeverity }) { return ( {severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"} ) } // ─── alert rule row ─────────────────────────────────────────────────────────── function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }: { rule: AlertRule onToggle: (id: string, enabled: boolean) => void onDelete: (id: string) => void onEdit?: (id: string) => void interactionsDisabled?: boolean }) { const { Icon, iconClass, bg } = TYPE_META[rule.type] return (
{/* toggle */} onToggle(rule.id, v)} disabled={interactionsDisabled} /> {/* severity dot */} {/* type icon */}
{/* name + target */}

{rule.name}

{TYPE_META[rule.type].label} ·{" "} {rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target} {rule.groupId ? ( · группа ) : null} {" · "}{rule.condition}

{/* cooldown */} {rule.cooldown} {formatConfirmStabilityShort(rule.confirmStabilitySec) ? ( ⏱ {formatConfirmStabilityShort(rule.confirmStabilitySec)} ) : null} {/* chat override badge */} {rule.chatId && ( #{rule.chatId.slice(-6)} )} {/* last fired */} {rule.lastFired ?? "—"} {/* severity badge */}
{/* actions (visible on hover) */}
{onEdit && ( )}
) } // ─── telegram config card ───────────────────────────────────────────────────── 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 = async () => { setTesting(true) setTestResult(null) 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) setTimeout(() => setTestResult(null), 4000) } } return ( ✈️ Telegram
} headerRight={ {cfg.connected ? "Подключён" : "Не настроен"} } contentClassName="px-4 pb-4 flex flex-col gap-3" > {/* bot token */}
Bot Token
onChange({ ...cfg, token: e.target.value })} autoComplete="new-password" name="telegram-bot-token" className="text-xs font-mono h-8" placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."} />
{/* default chat id */}
Chat ID по умолчанию onChange({ ...cfg, chatId: e.target.value })} className="text-xs font-mono h-8" placeholder="-100xxxxxxxxxx" />

Для групп используйте отрицательный ID. Каждое правило может переопределить.

ID темы (message_thread_id) onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })} className="text-xs font-mono h-8" inputMode="numeric" placeholder="Напр. 12345 — только для супергрупп с темами" /> Опционально: номер топика в форум-супергруппе. Пусто — сообщение в общий чат группы.
{onSaveTelegram && ( )} {/* test button */} ) } // ─── history card ───────────────────────────────────────────────────────────── function HistoryCard({ entries }: { entries: HistoryEntry[] }) { return ( {entries.length === 0 ? (

Нет срабатываний

) : (
{entries.map(e => (

{e.ruleName}

{e.message}

{e.time}

{e.sent ? "✓ отправлено" : "✗ ошибка"}
))}
)}
) } const SERVER_ROLES_RU: Record = { "jump-host": "Транзитный узел", "exit-node": "Выходной узел", "home-router": "Домашний роутер", } function serverRowLabel(s: AlertsServerRow): string { return (s.name?.trim() || s.host || "").trim() } /** Типы, где «объект» — сервер из каталога: показываем развёрнутые карточки. */ const OBJECT_TYPES_WITH_SERVER_CARDS: AlertType[] = ["server", "gre-client", "traffic"] /** Карточка сервера: поиск, флаг страны, имя, host, площадка, роль (как GRE-карточки). */ function ServerObjectCardPicker({ label, rows, selected, onToggle, hideLabel, /** Родитель задаёт высоту и скролл (блок в шите) */ listUnconstrained, }: { label: string rows: AlertsServerRow[] selected: string[] onToggle: (serverTitle: 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((row) => { const cc = iso2CountryCode(row.country) const blob = [ serverRowLabel(row), row.host, row.site, row.country, cc ? countryName(cc) : "", SERVER_ROLES_RU[row.type] ?? row.type, ] .join(" ") .toLowerCase() return blob.includes(s) }) }, [rows, query]) return (
{!hideLabel ? {label} : null} {rows.length === 0 ? (

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

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

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

) : null} {filteredRows.map((s) => { const title = serverRowLabel(s) const active = selected.includes(title) const loc = [s.site, s.country].filter(Boolean).join(" · ") const cc = iso2CountryCode(s.country) return ( ) })}
)}
) } /** Карточки 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: RuleFormSnapshot = { name: "", type: "server", targets: [], groupId: null, conditions: [], threshold: "", severity: "warning", cooldown: "5м", confirmStabilitySec: null, chatId: "", } 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 /** 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, ...(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 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 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 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) 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(), }) toast.success("Тестовое сообщение отправлено в Telegram.") } catch (e) { toast.error(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: 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() }}> {/* p-0 + gap-0: we control all spacing internally */} {/* ── fixed header ── */} {editRuleId ? "Изменение правила" : "Новое правило оповещения"} {/* ── scrollable body ── */}
{/* name */}
Название правила
{ 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 и восстановление.

)}
{/* threshold — only for RTT / loss / traffic */} {unit && (
Пороговое значение
{ nameTouchedRef.current = false setForm((f) => ({ ...f, threshold: e.target.value })) }} className="rounded-r-none" placeholder="0" /> {unit}
)} {/* severity — vertical radio cards */}
Серьёзность
{(["critical", "warning", "info"] as AlertSeverity[]).map(s => { const active = form.severity === s return ( ) })}
{/* cooldown */}
Повторять не чаще чем
{COOLDOWNS.map(c => ( ))}
{/* подтверждение стабильности (антидребезг) */}
Задержка перед отправкой
{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" /> Пусто → используется глобальный Chat ID из настроек Telegram
{/* Telegram message preview */} {form.name && form.targets.length > 0 && form.conditions.length > 0 && (
Предпросмотр сообщения

{form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"} {" "}{form.name}

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

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

Cooldown: {form.cooldown}

Chat: {form.chatId || "(глобальный)"}

)}
{/* ── fixed footer ── */} {isLive && onTestRuleTelegram ? ( ) : null}
) } // ─── page ───────────────────────────────────────────────────────────────────── type SeverityFilter = AlertSeverity | "all" export default function AlertsPage() { 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 [pendingPresetReplaceId, setPendingPresetReplaceId] = useState(null) 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 [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) => { 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 const critical = rules.filter(r => r.severity === "critical").length const warning = rules.filter(r => r.severity === "warning").length const info = rules.filter(r => r.severity === "info").length const filteredRules = useMemo(() => rules.filter(r => { if (sevFilter !== "all" && r.severity !== sevFilter) return false if (onlyActive && !r.enabled) 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) => { 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) const chipActive = (sev: SeverityFilter) => sevFilter === sev return (
} />
{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 ── */}
{/* stat chips */} {([ { key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" }, { key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip }, { key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip }, { key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip }, ] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => ( ))}
{/* active count chip */} {/* telegram status */} {tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
{/* ── presets + groups (свёрнуто по умолчанию) ── */}
Пресеты и группы ANY / ALL Массовое добавление правил и объединение нескольких правил в одно уведомление
{groups.length ? `${groups.length} гр.` : "групп нет"}

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

{ALERT_PRESETS.map((p) => (

{p.title}

{p.description}

))}
Группа } contentClassName="px-4 pb-4 flex flex-col gap-3" > {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 ── */}
{/* ── rules card ── */} Правила оповещения {rulesSaveBusy && } } headerRight={ setSearch(e.target.value)} autoComplete="off" name="alert-rules-search" className="h-7 text-xs max-w-[200px]" /> } contentClassName="px-0 pb-0 pt-2" > {filteredRules.length === 0 ? (

Правила не найдены

) : (
{filteredRules.map(r => ( ))}
)} {/* footer */}
{filteredRules.length} из {total} правил {onlyActive && " · только активные"} {sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
{/* ── right sidebar ── */}
{ if (!open) setPendingPresetReplaceId(null) }} > Заменить все правила? Текущие правила будут заменены выбранным пресетом. Это нельзя отменить. Отмена { if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId) setPendingPresetReplaceId(null) }} > Заменить
) }