Files
MikrotikManager/app/(main)/alerts/page.tsx
T
DenozordecandCursor 5f31bb47fb chore: synchronize pending app/backend updates and repository hygiene
Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 12:29:04 +07:00

3220 lines
134 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
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, 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 ────────────────────────────────────────────────────────────────────
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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>
}
}
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<string>()
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*</i.test(line) ? "TX < порога" : "RX < порога"
return line.trim()
}
/** Разбор сохранённого условия с порогом обратно в поля формы. */
function ruleToFormSnapshot(r: AlertRule): RuleFormSnapshot {
const unit = THRESHOLD_UNIT[r.type]
const rawConds =
r.conditions.length > 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<AlertType, string[]> {
return {
"gre-tunnel": [],
"bgp-peer": [],
"bgp-prefix": [],
"gre-client": [],
server: [],
rtt: [],
loss: [],
traffic: [],
}
}
/** Live: только данные из meta (и bgpPeers), без моковых подписей. */
function buildLiveTypeTargets(meta: AlertsMeta): Record<AlertType, string[]> {
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<AlertType, string> = {
"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<AlertType, {
Icon: React.FC<{ className?: string }>
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<AlertSeverity, { label: string; dot: string; badge: string; chip: string }> = {
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<AlertType, string[]> = {
"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<string, { label: string; dot: string }> = {
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<AlertType, string[]> = {
"gre-tunnel": ["перешёл в offline", "восстановился"],
"bgp-peer": ["разорвал сессию", "восстановил сессию"],
"bgp-prefix": ["был отозван", "был получен"],
"gre-client": ["отключился", "подключился"],
"server": ["перешёл в offline", "перешёл в degraded", "восстановился"],
"rtt": ["> порога", "< порога"],
"loss": ["> порога"],
"traffic": ["RX < порога", "TX < порога"],
}
const THRESHOLD_UNIT: Partial<Record<AlertType, string>> = {
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<AlertRule, "id" | "lastFired" | "enabled">[]
}
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 Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
onClick={() => { if (!disabled) onChange(!checked) }}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
disabled && "opacity-50 pointer-events-none",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)} />
</button>
)
}
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
}
function SeverityDot({ severity }: { severity: AlertSeverity }) {
return <span className={cn("inline-block size-2 rounded-full shrink-0", SEVERITY_META[severity].dot)} />
}
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
return (
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap", SEVERITY_META[severity].badge)}>
{severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
</span>
)
}
// ─── 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 (
<div className={cn(
"flex items-center gap-3 px-4 py-3 hover:bg-muted/20 transition-colors group",
!rule.enabled && "opacity-55",
)}>
{/* toggle */}
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
{/* severity dot */}
<SeverityDot severity={rule.severity} />
{/* type icon */}
<div className={cn("size-7 rounded-md flex items-center justify-center shrink-0", bg)}>
<Icon className={cn("size-3.5", iconClass)} />
</div>
{/* name + target */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{rule.name}</p>
<p className="text-[11px] text-muted-foreground truncate">
{TYPE_META[rule.type].label} ·{" "}
<span className="font-mono">
{rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
</span>
{rule.groupId ? (
<span className="text-muted-foreground/80"> · группа</span>
) : null}
{" · "}{rule.condition}
</p>
</div>
{/* cooldown */}
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
{rule.cooldown}
</span>
{formatConfirmStabilityShort(rule.confirmStabilitySec) ? (
<span
className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground shrink-0 max-w-[72px] truncate"
title={`Подтверждение: ${formatConfirmStabilityShort(rule.confirmStabilitySec)}`}
>
{formatConfirmStabilityShort(rule.confirmStabilitySec)}
</span>
) : null}
{/* chat override badge */}
{rule.chatId && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground hidden xl:inline shrink-0">
#{rule.chatId.slice(-6)}
</span>
)}
{/* last fired */}
<span className={cn(
"text-[11px] shrink-0 w-[80px] text-right",
rule.lastFired ? "text-muted-foreground" : "text-muted-foreground/40",
)}>
{rule.lastFired ?? "—"}
</span>
{/* severity badge */}
<div className="hidden lg:block shrink-0">
<SeverityBadge severity={rule.severity} />
</div>
{/* actions (visible on hover) */}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{onEdit && (
<button
type="button"
disabled={interactionsDisabled}
onClick={() => onEdit(rule.id)}
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
title="Изменить"
>
<PencilIcon className="size-3.5" />
</button>
)}
<button
type="button"
disabled={interactionsDisabled}
onClick={() => onDelete(rule.id)}
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
)
}
// ─── 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<void>
onTestTelegram?: () => void | Promise<void>
}) {
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 (
<Card>
<CardHeader className="pb-3 pt-4 px-4">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-base">✈️</span> Telegram
</span>
<span className={cn(
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
cfg.connected
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "bg-muted text-muted-foreground",
)}>
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
{cfg.connected ? "Подключён" : "Не настроен"}
</span>
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 space-y-3">
{/* bot token */}
<div>
<FieldLabel>Bot Token</FieldLabel>
<div className="flex gap-1.5">
<Input
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={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."}
/>
<button
onClick={() => setShowToken(v => !v)}
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
<button
onClick={() => navigator.clipboard.writeText(cfg.token)}
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
<CopyIcon className="size-3.5" />
</button>
</div>
</div>
{/* default chat id */}
<div>
<FieldLabel>Chat ID по умолчанию</FieldLabel>
<Input
value={cfg.chatId}
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
className="text-xs font-mono h-8"
placeholder="-100xxxxxxxxxx"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Для групп используйте отрицательный ID. Каждое правило может переопределить.
</p>
</div>
<div>
<FieldLabel>ID темы (message_thread_id)</FieldLabel>
<Input
value={cfg.messageThreadId}
onChange={e => onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })}
className="text-xs font-mono h-8"
inputMode="numeric"
placeholder="Напр. 12345 — только для супергрупп с темами"
/>
<FieldHint>
Опционально: номер топика в форум-супергруппе. Пусто сообщение в общий чат группы.
</FieldHint>
</div>
{onSaveTelegram && (
<Button
size="sm" className="w-full h-8 text-xs"
disabled={!!liveSaveBusy}
onClick={() => { void onSaveTelegram() }}
>
{liveSaveBusy ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
{liveSaveBusy ? "Сохранение…" : "Сохранить в БД"}
</Button>
)}
{/* test button */}
<Button
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
onClick={() => { void handleTest() }} disabled={testing}>
{testing ? (
<RefreshCwIcon className="size-3.5 animate-spin" />
) : testResult === "ok" ? (
<CheckIcon className="size-3.5 text-emerald-500" />
) : testResult === "fail" ? (
<XIcon className="size-3.5 text-destructive" />
) : (
<SendIcon className="size-3.5" />
)}
{testing ? "Отправка…"
: testResult === "ok" ? "Сообщение отправлено"
: testResult === "fail" ? "Ошибка отправки"
: "Отправить тестовое сообщение"}
</Button>
</CardContent>
</Card>
)
}
// ─── history card ─────────────────────────────────────────────────────────────
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
return (
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4">
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
) : (
<div className="flex flex-col gap-2.5">
{entries.map(e => (
<div key={e.id} className="flex gap-2.5 items-start">
<SeverityDot severity={e.severity} />
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium leading-tight truncate">{e.ruleName}</p>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-2">{e.message}</p>
</div>
<div className="text-right shrink-0">
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
<span className={cn(
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
e.sent
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
)}>
{e.sent ? "✓ отправлено" : "✗ ошибка"}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
const SERVER_ROLES_RU: Record<string, string> = {
"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 (
<div className="flex flex-col gap-1.5 min-w-0">
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
{rows.length === 0 ? (
<p className="text-[10px] text-muted-foreground leading-snug">Нет серверов для выбора</p>
) : (
<>
<div className="relative shrink-0">
<SearchIcon
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
name="alert-server-search"
placeholder="Поиск: сервер, площадка, IP, роль…"
className="h-8 pl-8 pr-2 text-xs"
aria-label="Поиск по списку серверов"
/>
</div>
<div
role="listbox"
aria-label={label}
className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained
? "overflow-visible"
: "max-h-[min(40vh,16rem)] overflow-y-auto",
)}
>
{filteredRows.length === 0 ? (
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
) : 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 (
<button
key={`${title}\0${s.host}`}
type="button"
role="option"
aria-selected={active}
onClick={() => onToggle(title)}
className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
active
? "border-primary bg-primary/8 ring-1 ring-primary/30"
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
)}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<div className="flex items-start gap-1.5 min-w-0 flex-1">
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground mt-px" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5 min-w-0">
{cc ? (
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
) : null}
<span className="text-[11px] font-semibold text-foreground truncate">{title}</span>
</div>
<p className="text-[10px] font-mono text-muted-foreground truncate">{s.host || "—"}</p>
{loc ? (
<p className="text-[10px] text-muted-foreground/90 truncate">{loc}</p>
) : null}
<p className="text-[9px] text-muted-foreground/80">
{SERVER_ROLES_RU[s.type] ?? s.type}
</p>
</div>
</div>
{active ? (
<CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-px" aria-hidden />
) : null}
</div>
</button>
)
})}
</div>
</>
)}
</div>
)
}
/** Карточки 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 (
<div className="flex flex-col gap-1.5 min-w-0">
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
{rows.length === 0 ? (
<p className="text-[10px] text-muted-foreground leading-snug">Нет туннелей для выбора</p>
) : (
<>
<div className="relative shrink-0">
<SearchIcon
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
name="alert-gre-search"
placeholder="Поиск: имя, роутер, IP, площадка…"
className="h-8 pl-8 pr-2 text-xs"
aria-label="Поиск по списку GRE-туннелей"
/>
</div>
<div
role="listbox"
aria-label={label}
className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto",
)}
>
{filteredRows.length === 0 ? (
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
) : 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 (
<button
key={`${r.targetLabel}\0${r.remoteAddress}`}
type="button"
role="option"
aria-selected={active}
onClick={() => onToggle(r.targetLabel)}
className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
active
? "border-primary bg-primary/8 ring-1 ring-primary/30"
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
)}
>
<div className="flex items-start justify-between gap-1.5 w-full min-w-0">
<div className="flex items-start gap-1.5 min-w-0 flex-1">
<CableIcon className="size-3.5 shrink-0 text-violet-500/90 mt-px" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-1.5 min-w-0">
{cc ? (
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
) : null}
<span className="text-[11px] font-medium text-foreground truncate">{siteLine}</span>
</div>
<p className="text-[11px] text-foreground truncate">
<span className="text-muted-foreground/85">Роутер</span>{" "}
<span className="font-medium">{r.routerName}</span>
</p>
<p className="text-[11px] truncate">
<span className="text-muted-foreground/85">Название</span>{" "}
<span className="font-mono font-semibold text-foreground">{r.tunnelName}</span>
</p>
{r.remoteAddress && r.remoteAddress !== "—" ? (
<p className="text-[10px] font-mono text-muted-foreground/90 truncate">
{r.remoteAddress}
</p>
) : null}
</div>
</div>
<span className="flex items-center gap-1.5 shrink-0 text-[9px] text-muted-foreground pt-px">
<span className="flex items-center gap-1">
<span className={cn("size-1.5 rounded-full shrink-0", st.dot)} aria-hidden />
<span className="whitespace-nowrap leading-none">{st.label}</span>
</span>
{active ? (
<CheckIcon className="size-4 shrink-0 text-primary opacity-70" aria-hidden />
) : null}
</span>
</div>
{r.comment ? (
<p className="text-[9px] text-muted-foreground/80 line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
{r.comment}
</p>
) : null}
</button>
)
})}
</div>
</>
)}
</div>
)
}
/** Карточки объекта для типов со строковым списком (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 (
<div className="flex flex-col gap-1.5 min-w-0">
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
{options.length === 0 ? (
<p className="text-[10px] text-muted-foreground leading-snug">Нет объектов для выбора</p>
) : (
<>
<div className="relative shrink-0">
<SearchIcon
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
name="alert-target-search"
placeholder="Поиск по списку объектов…"
className="h-8 pl-8 pr-2 text-xs"
aria-label="Поиск по списку объектов"
/>
</div>
<div
role="listbox"
aria-label={label}
className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto",
)}
>
{filtered.length === 0 ? (
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
) : null}
{filtered.map((opt) => {
const active = selected.includes(opt)
const hint = discreteTargetSubtitle(ruleType, opt)
return (
<button
key={opt}
type="button"
role="option"
aria-selected={active}
onClick={() => onToggle(opt)}
className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
active
? "border-primary bg-primary/8 ring-1 ring-primary/30"
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
)}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<div className="flex items-start gap-1.5 min-w-0 flex-1">
<Icon className={cn("size-3.5 shrink-0 mt-px", m.iconClass)} aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-[11px] font-semibold text-foreground break-words">{opt}</p>
{hint ? (
<p className="text-[10px] text-muted-foreground/90 truncate">{hint}</p>
) : null}
</div>
</div>
{active ? (
<CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-px" aria-hidden />
) : null}
</div>
</button>
)
})}
</div>
</>
)}
</div>
)
}
/** Выбор условия: как объекты — несколько карточек, 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 (
<div className="flex flex-col gap-1.5 min-w-0">
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
{conditions.length === 0 ? (
<p className="text-[10px] text-muted-foreground leading-snug">Нет условий для этого типа</p>
) : (
<>
<div className="relative shrink-0">
<SearchIcon
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
name="alert-condition-search"
placeholder="Поиск по вариантам условия…"
className="h-8 pl-8 pr-2 text-xs"
aria-label="Поиск по условиям"
/>
</div>
<div
role="listbox"
aria-label={label}
aria-multiselectable
className={cn(
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
listUnconstrained ? "overflow-visible" : "max-h-[min(36vh,14rem)] overflow-y-auto",
)}
>
{filtered.length === 0 ? (
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
) : null}
{filtered.map((opt) => {
const active = selected.includes(opt)
const { Icon: CondIcon, className: condIconCls } = conditionLeadingIcon(opt)
return (
<button
key={opt}
type="button"
role="option"
aria-selected={active}
onClick={() => onToggle(opt)}
className={cn(
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
active
? "border-primary bg-primary/8 ring-1 ring-primary/30"
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
)}
>
<div className="flex items-start gap-2 min-w-0 w-full">
<div
className={cn(
"flex h-7 min-w-[2.125rem] shrink-0 items-center justify-center gap-0.5 rounded-md px-1",
m.bg,
)}
>
<m.Icon className={cn("size-3 shrink-0", m.iconClass)} aria-hidden />
<CondIcon className={cn("size-3 shrink-0", condIconCls)} aria-hidden />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-[11px] font-semibold text-foreground break-words">{opt}</p>
<p className="text-[9px] text-muted-foreground/85 leading-tight">{m.label}</p>
</div>
{active ? (
<CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-0.5" aria-hidden />
) : null}
</div>
</button>
)
})}
</div>
</>
)}
</div>
)
}
/** Свернутый выбранный объект — развернуть список снова (без второй рамки: внешний контейнер уже обводит блок). */
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 (
<button
type="button"
onClick={onExpand}
className={cn(
"group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
"bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<CableIcon className="size-3.5 shrink-0 text-violet-500/85" aria-hidden />
<div className="min-w-0 flex-1 text-left space-y-0.5">
<div className="flex items-center gap-1.5 min-w-0">
{cc ? (
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
) : null}
<p className="text-[11px] font-medium text-foreground truncate">{siteLine}</p>
</div>
<p className="text-[11px] text-muted-foreground truncate">
<span className="text-foreground font-medium">{greTunnel.routerName}</span>
<span className="mx-1 text-muted-foreground/45">·</span>
<span className="font-mono text-foreground">{greTunnel.tunnelName}</span>
</p>
</div>
<span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
<ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
Изменить
</span>
</button>
)
}
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 (
<button
type="button"
onClick={onExpand}
className={cn(
"group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
"bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden />
<div className="min-w-0 flex-1 text-left space-y-0.5">
<div className="flex items-center gap-1.5 min-w-0">
{cc ? (
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
) : null}
<p className="text-[12px] font-medium text-foreground truncate">{target}{multiHint}</p>
</div>
{line2 ? (
<p
className={cn(
"text-[11px] text-muted-foreground truncate",
line2.mono && "font-mono",
)}
>
{line2.text}
</p>
) : null}
</div>
<span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
<ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
Изменить
</span>
</button>
)
}
const m = TYPE_META[ruleType]
const Icon = m.Icon
const sub = discreteTargetSubtitle(ruleType, target)
const joined = targets.length > 1 ? targets.join(", ") : target
return (
<button
type="button"
onClick={onExpand}
className={cn(
"group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
"bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<Icon className={cn("size-3.5 shrink-0", m.iconClass)} aria-hidden />
<div className="min-w-0 flex-1 text-left space-y-0.5">
<p className="text-[12px] font-medium text-foreground break-words">{joined}</p>
{sub ? (
<p className="text-[11px] text-muted-foreground truncate">{sub}</p>
) : null}
</div>
<span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
<ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
Изменить
</span>
</button>
)
}
// ─── 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<AlertRule, "id" | "lastFired" | "enabled">, editRuleId: string | null) => void
typeTargets: Record<AlertType, string[]>
isLive: boolean
serversCatalog: AlertsServerRow[]
greTunnelCatalog: AlertsGreTunnelRow[]
groups: AlertGroup[]
initialForm: RuleFormSnapshot | null
editRuleId: string | null
/** Live: отправка тестового сообщения с текстом как у сохранённого правила (без записи в БД). */
onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise<void>
/** true — кнопка теста неактивна (например, бэкенд недоступен). */
testTelegramDisabled?: boolean
}) {
const [form, setForm] = useState<RuleFormSnapshot>(() => ({
...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<string | null>(null)
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)
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: 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 (
<Sheet open={open} onOpenChange={(o) => { if (!o) onClose() }}>
{/* p-0 + gap-0: we control all spacing internally */}
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-lg">
{/* ── fixed header ── */}
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<SheetTitle className="flex items-center gap-2 text-base">
<BellIcon className="size-4" />
{editRuleId ? "Изменение правила" : "Новое правило оповещения"}
</SheetTitle>
</SheetHeader>
{/* ── scrollable body ── */}
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
{/* name */}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<FieldLabel>Название правила</FieldLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-[11px] px-2 text-muted-foreground shrink-0"
onClick={() => {
revealObjectPicker()
applySuggestedName()
}}
>
Сгенерировать
</Button>
</div>
<Input
placeholder="Подставится при выборе объекта или условия и будет обновляться при каждом шаге"
value={form.name}
onFocus={revealObjectPicker}
onChange={e => {
nameTouchedRef.current = true
setForm((f) => ({ ...f, name: e.target.value }))
}}
/>
{!editRuleId && (
<p className="text-[10px] text-muted-foreground">
Название пересобирается при смене типа, объекта, условия или порога. После ручного ввода в поле автоподстановка отключается.
</p>
)}
</div>
{/* type — карточки как «Действие» на /filters */}
<div className="flex flex-col gap-2">
<FieldLabel>Тип события</FieldLabel>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{ALERT_TYPES_ORDER.map((t) => {
const m = TYPE_META[t]
const { Icon, iconClass, bg } = m
const active = form.type === t
return (
<button
key={t}
type="button"
onFocus={revealObjectPicker}
onClick={() => {
nameTouchedRef.current = false
setObjectPickerRevealed(true)
setForm((f) => ({
...f,
type: t,
targets: [],
conditions: [],
threshold: "",
}))
queueMicrotask(() => setObjectExpanded(true))
}}
className={cn(
"flex flex-col items-start gap-2 rounded-lg border px-3 py-3 text-left transition-all w-full",
active
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30",
)}
>
<div className="flex items-center gap-2 w-full min-w-0">
<div className={cn("size-8 rounded-md flex items-center justify-center shrink-0", bg)}>
<Icon className={cn("size-4", iconClass)} />
</div>
<span className={cn(
"text-xs font-semibold truncate flex-1",
active ? "text-primary" : "text-foreground",
)}>
{m.label}
</span>
{active && <CheckIcon className="size-3.5 shrink-0 text-primary" />}
</div>
<span className="text-[10px] text-muted-foreground leading-tight pl-0">
{TYPE_EVENT_CARD_DESC[t]}
</span>
</button>
)
})}
</div>
</div>
<Separator />
{/* Объект и условие — вертикально; после выбора объекта список сворачивается, условия появляются */}
<div className="flex flex-col gap-4">
{allowManualTarget ? (
<div className="flex flex-col gap-1.5 min-w-0">
<FieldLabel>Объект</FieldLabel>
{!objectPickerRevealed ? (
<p className="text-[10px] text-muted-foreground leading-snug">
Поле ввода появится после клика или фокуса на названии правила, типе события, серьёзности,
пороге, группе или Chat ID.
</p>
) : (
<>
<Input
value={form.targets[0] ?? ""}
onChange={e => {
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 && (
<p className="text-[10px] text-muted-foreground mt-0.5">
Нет готового списка для этого типа укажите объект вручную (как в данных мониторинга).
</p>
)}
</>
)}
</div>
) : (
<div className="flex flex-col gap-1.5 min-w-0">
<FieldLabel>Объект</FieldLabel>
<p className="text-[10px] text-muted-foreground leading-snug">
Можно выбрать несколько карточек сработает при событии на любом из них (OR).
</p>
{!objectPickerRevealed ? (
<p className="text-[10px] text-muted-foreground leading-snug">
Список объектов появится после клика или фокуса на названии правила, типе события, серьёзности,
пороге, группе или Chat ID.
</p>
) : (
<div
className={cn(
"rounded-xl border border-border/80 bg-muted/10 overflow-hidden",
"transition-[background-color] duration-300",
)}
>
<div
className={cn(
"grid transition-[grid-template-rows] duration-500 ease-[cubic-bezier(0.25,1,0.5,1)]",
objectExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<div className="p-3 max-h-[min(42vh,17rem)] overflow-y-auto overscroll-contain">
{useGreTunnelObjectCards ? (
<GreTunnelObjectCardPicker
hideLabel
listUnconstrained
label="Объект"
rows={objectGreCardRows}
selected={form.targets}
onToggle={toggleTarget}
/>
) : useServerObjectCards ? (
<ServerObjectCardPicker
hideLabel
listUnconstrained
label="Объект"
rows={objectServerCardRows}
selected={form.targets}
onToggle={toggleTarget}
/>
) : (
<StringOptionCardPicker
hideLabel
listUnconstrained
label="Объект"
ruleType={form.type}
options={targets}
selected={form.targets}
onToggle={toggleTarget}
/>
)}
</div>
</div>
</div>
<div
className={cn(
"grid transition-[grid-template-rows] duration-400 ease-[cubic-bezier(0.25,1,0.5,1)]",
!objectExpanded && hasObject ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="min-h-0 overflow-hidden">
<div className="p-3">
<ObjectPickSummaryRow
ruleType={form.type}
targets={form.targets}
server={objectSummaryGre ? undefined : objectSummaryServer}
greTunnel={objectSummaryGre}
onExpand={() => {
revealObjectPicker()
setObjectExpanded(true)
}}
/>
</div>
</div>
</div>
</div>
)}
</div>
)}
<div className="flex flex-col gap-1.5 min-w-0">
<FieldLabel className={cn(!hasObject && "text-muted-foreground")}>Условие</FieldLabel>
{!hasObject ? (
<p className="text-[10px] text-muted-foreground leading-snug">
{allowManualTarget
? "Укажите объект выше — затем появится выбор условия."
: "Сначала выберите объект (можно несколько). Варианты условия появятся ниже; после выбора условия список объектов свернётся — снова развернуть кнопкой «Изменить»."}
</p>
) : (
<>
<p className="text-[10px] text-muted-foreground leading-snug">
Можно выбрать несколько карточек уведомление при срабатывании любого из условий (OR), например offline и восстановление.
</p>
<div
className={cn(
"rounded-xl border border-border/80 bg-muted/10 overflow-hidden",
"transition-[opacity,transform,background-color] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
"opacity-100 translate-y-0 motion-reduce:transition-none",
)}
>
<div className="p-3 max-h-[min(40vh,16rem)] overflow-y-auto overscroll-contain">
<ConditionOptionCardPicker
hideLabel
listUnconstrained
label="Условие"
ruleType={form.type}
conditions={conditions}
selected={form.conditions}
onToggle={toggleCondition}
/>
</div>
</div>
</>
)}
</div>
</div>
{/* threshold — only for RTT / loss / traffic */}
{unit && (
<div className="flex flex-col gap-1.5">
<FieldLabel>Пороговое значение</FieldLabel>
<div className="flex items-center gap-0">
<Input
type="number" min="0"
value={form.threshold}
onFocus={revealObjectPicker}
onChange={e => {
nameTouchedRef.current = false
setForm((f) => ({ ...f, threshold: e.target.value }))
}}
className="rounded-r-none"
placeholder="0"
/>
<span className="h-8 px-3 flex items-center rounded-r-lg border border-l-0 border-input
bg-muted text-sm text-muted-foreground shrink-0">
{unit}
</span>
</div>
</div>
)}
{/* severity — vertical radio cards */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Серьёзность</FieldLabel>
<div className="flex flex-col gap-1.5">
{(["critical", "warning", "info"] as AlertSeverity[]).map(s => {
const active = form.severity === s
return (
<button
key={s}
type="button"
onFocus={revealObjectPicker}
onClick={() => {
revealObjectPicker()
setForm((f) => ({ ...f, severity: s }))
}}
className={cn(
"flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors w-full",
active ? SEVERITY_META[s].chip : "border-input hover:bg-muted/50 text-foreground",
)}>
<span className={cn("size-2.5 rounded-full shrink-0", SEVERITY_META[s].dot)} />
<div className="min-w-0">
<p className="text-sm font-medium leading-none">{SEVERITY_META[s].label}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
{s === "critical" ? "Немедленное уведомление, звуковой сигнал"
: s === "warning" ? "Важное событие, тихое уведомление"
: "Информационное, без уведомления"}
</p>
</div>
{active && <CheckIcon className="size-4 ml-auto shrink-0 opacity-70" />}
</button>
)
})}
</div>
</div>
{/* cooldown */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Повторять не чаще чем</FieldLabel>
<div className="flex gap-1.5 flex-wrap">
{COOLDOWNS.map(c => (
<button
key={c}
type="button"
onFocus={revealObjectPicker}
onClick={() => {
revealObjectPicker()
setForm((f) => ({ ...f, cooldown: c }))
}}
className={cn(
"px-3 py-1.5 rounded-lg border text-sm font-mono transition-colors",
form.cooldown === c
? "border-primary bg-primary/10 text-primary font-semibold"
: "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}>
{c}
</button>
))}
</div>
</div>
{/* подтверждение стабильности (антидребезг) */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Задержка перед отправкой</FieldLabel>
<div className="flex gap-1.5 flex-wrap">
{CONFIRM_STABILITY_OPTIONS.map((o) => {
const active =
(o.value == null && (form.confirmStabilitySec == null || form.confirmStabilitySec <= 0)) ||
(o.value != null && form.confirmStabilitySec === o.value)
return (
<button
key={o.label}
type="button"
onFocus={revealObjectPicker}
onClick={() => {
revealObjectPicker()
setForm((f) => ({ ...f, confirmStabilitySec: o.value }))
}}
className={cn(
"px-3 py-1.5 rounded-lg border text-sm transition-colors",
active
? "border-primary bg-primary/10 text-primary font-semibold"
: "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
{o.label}
</button>
)
})}
</div>
<FieldHint>
Если за выбранное время условие перестанет выполняться (сигнал «отменится»), уведомление не отправится.
Не зависит от cooldown: сначала ждём стабильность, затем проверяем cooldown после фактической отправки.
</FieldHint>
</div>
<div className="flex flex-col gap-1.5">
<FieldLabel>Группа правил (ANY / ALL)</FieldLabel>
<select
className={cn(
"h-9 w-full rounded-md border border-input bg-background px-2 text-xs",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
value={form.groupId ?? ""}
onFocus={revealObjectPicker}
onChange={(e) => {
const v = e.target.value
setForm((f) => ({ ...f, groupId: v ? v : null }))
}}
>
<option value="">Без группы (отдельное уведомление)</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({g.combineMode === "any" ? "ANY" : "ALL"})
{!g.enabled ? " · выкл." : ""}
</option>
))}
</select>
<FieldHint>
ANY уведомление при срабатывании любого правила в группе. ALL когда сработали все правила группы одновременно.
</FieldHint>
</div>
<div className="flex flex-col gap-1.5">
<FieldLabel>Chat ID переопределить</FieldLabel>
<Input
value={form.chatId}
onFocus={revealObjectPicker}
onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))}
className="font-mono text-sm"
placeholder="-100xxxxxxxxxx"
/>
<FieldHint>Пусто используется глобальный Chat ID из настроек Telegram</FieldHint>
</div>
{/* Telegram message preview */}
{form.name && form.targets.length > 0 && form.conditions.length > 0 && (
<div className="rounded-xl border border-border bg-muted/40 overflow-hidden">
<div className="px-3 py-2 border-b border-border/60 flex items-center gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
Предпросмотр сообщения
</span>
</div>
<div className="px-3 py-2.5 font-mono text-xs leading-relaxed space-y-0.5">
<p>
{form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"}
{" "}<span className="font-semibold">{form.name}</span>
</p>
<p className="text-muted-foreground">Объект: {form.targets.join(", ")}</p>
<p className="text-muted-foreground whitespace-pre-wrap">
Событие: {conditionDisplay || summarizeConditionsUi(form.conditions)}
</p>
<p className="text-muted-foreground">Cooldown: {form.cooldown}</p>
<p className="text-muted-foreground">
Chat: {form.chatId || "(глобальный)"}
</p>
</div>
</div>
)}
</div>
{isLive && onTestRuleTelegram && testTelegramHint ? (
<div
className={cn(
"shrink-0 px-6 py-2.5 text-xs border-t",
testTelegramHint === "__ok__"
? "text-emerald-700 dark:text-emerald-400 bg-emerald-500/10 border-emerald-500/20"
: "text-destructive bg-destructive/5 border-destructive/20",
)}
>
{testTelegramHint === "__ok__"
? "Тестовое сообщение отправлено в Telegram."
: testTelegramHint}
</div>
) : null}
{/* ── fixed footer ── */}
<SheetFooter className="shrink-0 px-6 py-4 border-t flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-stretch">
<Button variant="outline" className="w-full sm:flex-1 min-h-9" onClick={onClose}>
Отмена
</Button>
{isLive && onTestRuleTelegram ? (
<Button
type="button"
variant="secondary"
className="w-full sm:flex-1 min-h-9 inline-flex items-center justify-center gap-2"
disabled={!canSave || testTelegramBusy || testTelegramDisabled}
title={
testTelegramDisabled
? "Бэкенд недоступен"
: !canSave
? "Заполните название, объект, условие и при необходимости порог"
: undefined
}
onClick={() => void handleTestRuleTelegram()}
>
{testTelegramBusy ? (
<LoaderCircleIcon className="size-4 animate-spin shrink-0" aria-hidden />
) : (
<SendIcon className="size-4 shrink-0" aria-hidden />
)}
Тест в Telegram
</Button>
) : null}
<Button className="w-full sm:flex-1 min-h-9" onClick={handleSave} disabled={!canSave}>
{editRuleId ? "Сохранить изменения" : "Сохранить правило"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── 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<AlertRule[]>(INIT_RULES)
const [groups, setGroups] = useState<AlertGroup[]>(INIT_GROUPS)
const [history, setHistory] = useState<HistoryEntry[]>(INIT_HISTORY)
const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
const [tokenConfigured, setTokenConfigured] = useState(false)
const [meta, setMeta] = useState<AlertsMeta | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
const [sheetKey, setSheetKey] = useState(0)
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all")
const [onlyActive, setOnlyActive] = useState(false)
const [search, setSearch] = useState("")
const [loadBusy, setLoadBusy] = useState(false)
const [loadError, setLoadError] = useState<string | null>(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<BgpSessionListRow[]>("/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<AlertGroup>) => {
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<AlertRule, "id" | "lastFired" | "enabled">, 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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
actions={
<>
<Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
onClick={() => { void load() }}>
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
Обновить
</Button>
<Button size="sm" onClick={openRuleSheetAdd} disabled={isLive && backendStatus === false}>
<PlusIcon className="size-4" />Добавить правило
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{isLive && backendStatus === false && (
<p className="text-xs text-amber-700 dark:text-amber-400 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2">
Бэкенд недоступен по текущему URL. Проверьте адрес в настройках источника данных или переключитесь в режим демо.
</p>
)}
{!isLive && (
<p className="text-xs text-muted-foreground rounded-lg border border-border bg-muted/30 px-3 py-2">
Режим демо: показаны примерные данные без сохранения. Включите «Живые данные» в источнике данных для работы с БД.
</p>
)}
{loadError && (
<p className="text-xs text-destructive rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2">
{loadError}
</p>
)}
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", hintsExpanded && "rotate-180")} />
Подсказки: формат целей и «восстановился»
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
<p>
Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
срабатывает только если цепочка состояний совпадает с текстом правила.
</p>
<ul className="list-disc pl-4 space-y-1.5">
<li>
<span className="font-medium text-foreground">RTT / потери</span> в объектах укажите ключ пробы
как в мониторинге: <code className="rounded bg-muted px-1 font-mono">имя пробы target</code>.
</li>
<li>
<span className="font-medium text-foreground">Восстановился</span> нужна история: после падения
в последних сэмплах виден возврат в online (REST и метрики ресурса объединяются). Один текущий
online без прошлого offline часто не даёт hit.
</li>
<li>
<span className="font-medium text-foreground">Перешёл в offline</span> только на ребре (предыдущий
сэмпл был online). Пока сервер остаётся offline, повторные уведомления не шлются.
</li>
<li>
<span className="font-medium text-foreground">Стабильность / cooldown</span> задержка перед
Telegram и пауза между повторными отправками. В разделе «Сбор данных» в деталях прогона «Оповещения»
есть таблица по каждому правилу (hit, стабильность, cooldown, Telegram, причина блока).
</li>
</ul>
</CollapsibleContent>
</Collapsible>
{/* ── summary chip bar ── */}
<div className="flex items-center gap-2 flex-wrap">
{/* 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 }) => (
<button key={key} onClick={() => handleChip(key)}
className={cn(
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
chipActive(key)
? cls
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
)}>
{label}
</button>
))}
<div className="w-px h-4 bg-border mx-1" />
{/* active count chip */}
<button onClick={() => setOnlyActive(v => !v)}
className={cn(
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
onlyActive
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
)}>
Активных: {active}
</button>
{/* telegram status */}
<span className={cn(
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
tg.connected
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
)}>
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
</span>
</div>
{/* ── presets + groups (свёрнуто по умолчанию) ── */}
<Collapsible open={extrasExpanded} onOpenChange={setExtrasExpanded} className="rounded-lg border border-border bg-card shadow-sm">
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm outline-none transition-colors",
"hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<ChevronDownIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200",
extrasExpanded && "rotate-180",
)}
aria-hidden
/>
<div className="flex-1 min-w-0">
<span className="font-medium">Пресеты и группы ANY / ALL</span>
<span className="hidden sm:block text-[10px] text-muted-foreground font-normal leading-snug mt-0.5">
Массовое добавление правил и объединение нескольких правил в одно уведомление
</span>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 tabular-nums">
{groups.length ? `${groups.length} гр.` : "групп нет"}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border/60 px-4 pb-4 pt-3">
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<CardHeader className="pb-2 pt-4 px-4">
<CardTitle className="text-sm">Пресеты</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
<p className="text-[10px] text-muted-foreground leading-snug">
Быстро добавить набор правил из каталога серверов (демо или live meta).
</p>
{ALERT_PRESETS.map((p) => (
<div
key={p.id}
className="rounded-lg border border-border/80 bg-muted/5 p-3 flex flex-col gap-2"
>
<p className="text-xs font-semibold">{p.title}</p>
<p className="text-[10px] text-muted-foreground leading-snug">{p.description}</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 text-xs"
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
onClick={() => applyPresetAppend(p.id)}
>
Добавить к списку
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
onClick={() => applyPresetReplace(p.id)}
>
Заменить все
</Button>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2 pt-4 px-4">
<CardTitle className="text-sm">Группы ANY / ALL</CardTitle>
<Button
type="button"
size="sm"
variant="secondary"
className="h-7 text-xs shrink-0"
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
onClick={addGroup}
>
<PlusIcon className="size-3.5" />Группа
</Button>
</CardHeader>
<CardContent className="px-4 pb-4 flex flex-col gap-3">
{groups.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
Групп пока нет. Создайте группу и назначьте правилам её в форме правила движок отправит одно сообщение по логике ANY или ALL.
</p>
) : (
groups.map((g) => (
<div
key={g.id}
className="rounded-lg border border-border/80 p-3 flex flex-col gap-2"
>
<div className="flex flex-wrap items-center gap-2">
<Input
value={g.name}
onChange={(e) => patchGroup(g.id, { name: e.target.value })}
className="h-8 text-xs flex-1 min-w-[140px]"
aria-label="Имя группы"
/>
<Button
type="button"
size="sm"
variant={g.enabled ? "secondary" : "outline"}
className="h-7 text-xs"
onClick={() => patchGroup(g.id, { enabled: !g.enabled })}
>
{g.enabled ? "Вкл." : "Выкл."}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => removeGroup(g.id)}
>
Удалить
</Button>
</div>
<div className="flex flex-wrap gap-2 items-center">
<span className="text-[10px] text-muted-foreground shrink-0">Режим</span>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
value={g.combineMode}
onChange={(e) =>
patchGroup(g.id, { combineMode: e.target.value === "all" ? "all" : "any" })
}
>
<option value="any">ANY (или)</option>
<option value="all">ALL (и)</option>
</select>
<span className="text-[10px] text-muted-foreground shrink-0">Cooldown группы</span>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
value={g.cooldownOverride ?? ""}
onChange={(e) => {
const v = e.target.value
patchGroup(g.id, {
cooldownOverride: v === "" ? null : (v as AlertCooldown),
})
}}
>
<option value="">По умолчанию</option>
{COOLDOWNS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ── main layout ── */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
{/* ── rules card ── */}
<Card>
<CardHeader className="pb-0 pt-4 px-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-sm flex items-center gap-2">
Правила оповещения
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
</CardTitle>
<Input
placeholder="Поиск…"
value={search}
onChange={e => setSearch(e.target.value)}
autoComplete="off"
name="alert-rules-search"
className="h-7 text-xs max-w-[200px]"
/>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 pt-2">
{filteredRules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
<BellOffIcon className="size-8 mb-2 opacity-40" />
<p className="text-sm">Правила не найдены</p>
</div>
) : (
<div className="divide-y divide-border/60">
{filteredRules.map(r => (
<AlertRuleRow
key={r.id}
rule={r}
onToggle={handleToggle}
onDelete={handleDelete}
onEdit={openRuleSheetEdit}
interactionsDisabled={rulesSaveBusy}
/>
))}
</div>
)}
{/* footer */}
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{filteredRules.length} из {total} правил
{onlyActive && " · только активные"}
{sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
</span>
<button
type="button"
onClick={openRuleSheetAdd}
className="flex items-center gap-1 hover:text-foreground transition-colors">
<PlusIcon className="size-3" />Добавить правило
</button>
</div>
</CardContent>
</Card>
{/* ── right sidebar ── */}
<div className="flex flex-col gap-5">
<TelegramCard
cfg={tg}
onChange={setTg}
tokenConfigured={isLive ? tokenConfigured : false}
liveSaveBusy={telegramSaveBusy}
onSaveTelegram={isLive ? saveTelegram : undefined}
onTestTelegram={isLive ? testTelegram : undefined}
/>
<HistoryCard entries={history} />
</div>
</div>
</div>
</div>
<AddRuleSheet
key={sheetKey}
open={sheetOpen}
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}
/>
</div>
)
}