Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <cursoragent@cursor.com>
3199 lines
132 KiB
TypeScript
3199 lines
132 KiB
TypeScript
"use client"
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { FormToggle } from "@/components/form-kit"
|
||
import { OpsPanel } from "@/components/ops-panel"
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/components/ui/alert-dialog"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
|
||
} from "@/components/ui/sheet"
|
||
import {
|
||
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon,
|
||
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
|
||
TimerIcon, WifiOffIcon, TrendingDownIcon,
|
||
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
|
||
Trash2Icon,
|
||
PencilIcon,
|
||
ChevronsUpDownIcon,
|
||
SearchIcon,
|
||
AlertCircleIcon,
|
||
ChevronUpIcon,
|
||
ChevronDownIcon,
|
||
PlugIcon,
|
||
UnplugIcon,
|
||
} from "lucide-react"
|
||
import { Flag, countryName } from "@/components/flag"
|
||
import { cn } from "@/lib/utils"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { toast } from "sonner"
|
||
|
||
// ─── types ────────────────────────────────────────────────────────────────────
|
||
|
||
type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic"
|
||
type AlertSeverity = "critical" | "warning" | "info"
|
||
type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч"
|
||
type RecoveryMode = "always" | "never" | "conditional"
|
||
|
||
interface AlertRule {
|
||
id: string
|
||
name: string
|
||
type: AlertType
|
||
/** Сводная строка (как с бэкенда) */
|
||
target: string
|
||
/** Объекты правила (OR); если пусто — используется `target` */
|
||
targets: string[]
|
||
groupId: string | null
|
||
/** Сводная подпись условий в списке */
|
||
condition: string
|
||
/** Все условия (OR); при отсутствии — трактуем как `[condition]` */
|
||
conditions: string[]
|
||
severity: AlertSeverity
|
||
enabled: boolean
|
||
cooldown: AlertCooldown
|
||
/** Секунды: уведомление только если условие не пропало всё это время; null — выкл. */
|
||
confirmStabilitySec: number | null
|
||
recoveryMode?: RecoveryMode
|
||
recoveryStabilitySec?: number | null
|
||
lastFired: string | null // relative time string
|
||
chatId: string // override; empty = use global
|
||
}
|
||
|
||
interface AlertGroup {
|
||
id: string
|
||
name: string
|
||
combineMode: "any" | "all"
|
||
enabled: boolean
|
||
cooldownOverride: AlertCooldown | null
|
||
}
|
||
|
||
interface HistoryEntry {
|
||
id: string
|
||
ruleName: string
|
||
severity: AlertSeverity
|
||
message: string
|
||
time: string
|
||
sent: boolean
|
||
}
|
||
|
||
interface TelegramConfig {
|
||
token: string
|
||
chatId: string
|
||
/** Положительное число — тема супергруппы (forum), Bot API: `message_thread_id` */
|
||
messageThreadId: string
|
||
connected: boolean
|
||
}
|
||
|
||
interface AlertsApiRule {
|
||
id: string
|
||
name: string
|
||
type: AlertType
|
||
target: string
|
||
targets?: string[]
|
||
groupId?: string | null
|
||
condition: string
|
||
conditions?: string[]
|
||
severity: AlertSeverity
|
||
enabled: boolean
|
||
cooldown: AlertCooldown
|
||
confirmStabilitySec?: number | null
|
||
recoveryMode?: RecoveryMode
|
||
recoveryStabilitySec?: number | null
|
||
chatId: string
|
||
lastFiredAt: string | null
|
||
}
|
||
|
||
interface AlertsApiGroup {
|
||
id: string
|
||
name: string
|
||
combineMode: "any" | "all"
|
||
enabled: boolean
|
||
cooldownOverride: AlertCooldown | null
|
||
}
|
||
|
||
interface AlertsApiHistoryEntry {
|
||
id: string
|
||
ruleName: string
|
||
severity: AlertSeverity
|
||
message: string
|
||
sent: boolean
|
||
firedAt: string
|
||
}
|
||
|
||
interface AlertsServerRow {
|
||
name: string
|
||
host: string
|
||
site: string
|
||
country: string
|
||
type: string
|
||
}
|
||
|
||
/** Каталог GRE для карточек объекта; `targetLabel` = значение `target` в правиле. */
|
||
interface AlertsGreTunnelRow {
|
||
targetLabel: string
|
||
tunnelName: string
|
||
routerName: string
|
||
country: string
|
||
site: string
|
||
remoteAddress: string
|
||
status: "up" | "down" | "degraded" | string
|
||
comment?: string
|
||
}
|
||
|
||
interface AlertsMeta {
|
||
servers: string[]
|
||
greClients: string[]
|
||
probeTargets: string[]
|
||
rttLossTargets: string[]
|
||
trafficServers: string[]
|
||
/** Каталог для карточек «Объект» (сервер / GRE-клиент / трафик) */
|
||
serversDetail?: AlertsServerRow[]
|
||
/** Собирается на клиенте из `/api/bgp/sessions` в live-режиме */
|
||
bgpPeers?: string[]
|
||
/** Подписи «имя туннеля / сервер» из `/api/filters/gre-tunnels` + `/api/servers` (как на /gre) */
|
||
greTunnelTargets?: string[]
|
||
/** Расширенные поля для карточек выбора туннеля */
|
||
greTunnelDetail?: AlertsGreTunnelRow[]
|
||
}
|
||
|
||
/** Минимальный ответ BGP для подписей «роутер · сосед». */
|
||
interface BgpSessionListRow {
|
||
serverName: string
|
||
name: string
|
||
}
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function formatRelativeRu(iso: string): string {
|
||
const t = Date.parse(iso)
|
||
if (!Number.isFinite(t)) return iso
|
||
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000))
|
||
if (sec < 45) return "только что"
|
||
if (sec < 3600) return `${Math.max(1, Math.floor(sec / 60))} мин назад`
|
||
if (sec < 86400) return `${Math.max(1, Math.floor(sec / 3600))} ч назад`
|
||
const d = Math.floor(sec / 86400)
|
||
return `${d} д назад`
|
||
}
|
||
|
||
function uniqStrings(xs: string[]): string[] {
|
||
const o: string[] = []
|
||
const s = new Set<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 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 */}
|
||
<FormToggle 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 (
|
||
<OpsPanel
|
||
title={<span className="flex items-center gap-2"><span className="text-base">✈️</span> Telegram</span>}
|
||
headerRight={
|
||
<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>
|
||
}
|
||
contentClassName="px-4 pb-4 flex flex-col gap-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>
|
||
</OpsPanel>
|
||
)
|
||
}
|
||
|
||
// ─── history card ─────────────────────────────────────────────────────────────
|
||
|
||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||
return (
|
||
<OpsPanel title="Журнал срабатываний" contentClassName="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>
|
||
)}
|
||
</OpsPanel>
|
||
)
|
||
}
|
||
|
||
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 targets = typeTargets[form.type]
|
||
const conditions = TYPE_CONDITIONS[form.type]
|
||
const targetsEmpty = targets.length === 0
|
||
|
||
const objectServerCardRows = useMemo(() => {
|
||
if (!OBJECT_TYPES_WITH_SERVER_CARDS.includes(form.type)) return []
|
||
if (!serversCatalog.length) return []
|
||
const pick = new Set(targets)
|
||
return serversCatalog.filter((s) => pick.has(serverRowLabel(s)))
|
||
}, [form.type, serversCatalog, targets])
|
||
|
||
const objectGreCardRows = useMemo(() => {
|
||
if (form.type !== "gre-tunnel" || !greTunnelCatalog.length) return []
|
||
const pick = new Set(targets)
|
||
return greTunnelCatalog.filter((r) => pick.has(r.targetLabel))
|
||
}, [form.type, greTunnelCatalog, targets])
|
||
|
||
const useGreTunnelObjectCards = !targetsEmpty && objectGreCardRows.length > 0
|
||
const useServerObjectCards = !targetsEmpty && objectServerCardRows.length > 0
|
||
const unit = THRESHOLD_UNIT[form.type]
|
||
const allowManualTarget = targetsEmpty
|
||
const targetOk = allowManualTarget ? Boolean(form.targets[0]?.trim()) : form.targets.length > 0
|
||
const hasObject = form.targets.length > 0
|
||
|
||
const objectSummaryServer = useMemo((): AlertsServerRow | undefined => {
|
||
if (!useServerObjectCards) return undefined
|
||
for (const t of form.targets) {
|
||
const s = objectServerCardRows.find((x) => serverRowLabel(x) === t.trim())
|
||
if (s) return s
|
||
}
|
||
return undefined
|
||
}, [useServerObjectCards, objectServerCardRows, form.targets])
|
||
|
||
const objectSummaryGre = useMemo((): AlertsGreTunnelRow | undefined => {
|
||
if (!useGreTunnelObjectCards) return undefined
|
||
for (const t of form.targets) {
|
||
const r = objectGreCardRows.find((x) => x.targetLabel === t.trim())
|
||
if (r) return r
|
||
}
|
||
return undefined
|
||
}, [useGreTunnelObjectCards, objectGreCardRows, form.targets])
|
||
|
||
const toggleTarget = useCallback((v: string) => {
|
||
const t = v.trim()
|
||
if (!t) return
|
||
nameTouchedRef.current = false
|
||
setForm((f) => {
|
||
const set = new Set(f.targets)
|
||
if (set.has(t)) set.delete(t)
|
||
else set.add(t)
|
||
const next = [...set]
|
||
// Не сворачиваем после каждого клика по карточке — иначе мульти-выбор серверов невозможен без повторного «Изменить».
|
||
queueMicrotask(() => {
|
||
if (next.length === 0) setObjectExpanded(true)
|
||
})
|
||
return { ...f, targets: next }
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
if (nameTouchedRef.current) return
|
||
const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0
|
||
queueMicrotask(() => {
|
||
if (trivialEmpty) {
|
||
if (!editRuleId) {
|
||
setForm((f) => (f.name === "" ? f : { ...f, name: "" }))
|
||
}
|
||
return
|
||
}
|
||
const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold)
|
||
setForm((f) => (f.name === nextName ? f : { ...f, name: nextName }))
|
||
})
|
||
}, [open, editRuleId, form.type, form.targets, form.conditions, form.threshold])
|
||
|
||
const expandedConditionLines = useMemo(
|
||
() =>
|
||
form.conditions.map((c) =>
|
||
unit && form.threshold.trim() && c.includes("порога")
|
||
? c.replace("порога", `${form.threshold.trim()} ${unit}`)
|
||
: c,
|
||
),
|
||
[form.conditions, form.threshold, unit],
|
||
)
|
||
|
||
const conditionDisplay = expandedConditionLines.join("\n")
|
||
|
||
const canSave =
|
||
form.name.trim() && targetOk && form.conditions.length > 0 && (!unit || form.threshold)
|
||
|
||
const handleTestRuleTelegram = async () => {
|
||
if (!onTestRuleTelegram || !canSave || testTelegramBusy || testTelegramDisabled) return
|
||
setTestTelegramBusy(true)
|
||
try {
|
||
const conditionLine = conditionDisplay || summarizeConditionsUi(form.conditions)
|
||
await onTestRuleTelegram({
|
||
name: form.name.trim(),
|
||
targets: uniqStrings(form.targets),
|
||
conditionLine,
|
||
severity: form.severity,
|
||
cooldown: form.cooldown,
|
||
ruleChatId: form.chatId.trim(),
|
||
})
|
||
toast.success("Тестовое сообщение отправлено в Telegram.")
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "Не удалось отправить тест")
|
||
} finally {
|
||
setTestTelegramBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleSave = () => {
|
||
if (!canSave) return
|
||
const uq = uniqStrings(form.targets)
|
||
const targetSummary = uq.join(", ").slice(0, 240) || "—"
|
||
const condResolved = uniqStrings(expandedConditionLines.map((c) => c.trim()).filter(Boolean))
|
||
onSave({
|
||
name: form.name.trim(),
|
||
type: form.type,
|
||
target: targetSummary,
|
||
targets: uq,
|
||
groupId: form.groupId && groups.some((g) => g.id === form.groupId) ? form.groupId : null,
|
||
condition: summarizeConditionsUi(condResolved),
|
||
conditions: condResolved,
|
||
severity: form.severity,
|
||
cooldown: form.cooldown,
|
||
confirmStabilitySec: form.confirmStabilitySec,
|
||
chatId: form.chatId.trim(),
|
||
}, editRuleId)
|
||
onClose()
|
||
}
|
||
|
||
const applySuggestedName = useCallback(() => {
|
||
nameTouchedRef.current = false
|
||
const trivialEmpty = form.targets.length === 0 && form.conditions.length === 0
|
||
if (!editRuleId && trivialEmpty) {
|
||
setForm((f) => ({ ...f, name: "" }))
|
||
return
|
||
}
|
||
const nextName = suggestAlertRuleName(form.type, form.targets, form.conditions, form.threshold)
|
||
setForm((f) => ({ ...f, name: nextName }))
|
||
}, [editRuleId, form.type, form.targets, form.conditions, form.threshold])
|
||
|
||
const toggleCondition = useCallback((v: string) => {
|
||
const t = v.trim()
|
||
if (!t) return
|
||
nameTouchedRef.current = false
|
||
setForm((f) => {
|
||
const set = new Set(f.conditions)
|
||
if (set.has(t)) set.delete(t)
|
||
else set.add(t)
|
||
const next = [...set]
|
||
queueMicrotask(() => setObjectExpanded(false))
|
||
return { ...f, conditions: next }
|
||
})
|
||
}, [])
|
||
|
||
return (
|
||
<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>
|
||
|
||
{/* ── 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 [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
|
||
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) => {
|
||
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">
|
||
<OpsPanel title="Пресеты" contentClassName="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={() => setPendingPresetReplaceId(p.id)}
|
||
>
|
||
Заменить все
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</OpsPanel>
|
||
|
||
<OpsPanel
|
||
title="Группы ANY / ALL"
|
||
headerRight={
|
||
<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>
|
||
}
|
||
contentClassName="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>
|
||
))
|
||
)}
|
||
</OpsPanel>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* ── main layout ── */}
|
||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
|
||
|
||
{/* ── rules card ── */}
|
||
<OpsPanel
|
||
title={
|
||
<span className="flex items-center gap-2">
|
||
Правила оповещения
|
||
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
|
||
</span>
|
||
}
|
||
headerRight={
|
||
<Input
|
||
placeholder="Поиск…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
autoComplete="off"
|
||
name="alert-rules-search"
|
||
className="h-7 text-xs max-w-[200px]"
|
||
/>
|
||
}
|
||
contentClassName="px-0 pb-0 pt-2"
|
||
>
|
||
{filteredRules.length === 0 ? (
|
||
<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>
|
||
</OpsPanel>
|
||
|
||
{/* ── 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}
|
||
/>
|
||
|
||
<AlertDialog
|
||
open={pendingPresetReplaceId != null}
|
||
onOpenChange={(open) => {
|
||
if (!open) setPendingPresetReplaceId(null)
|
||
}}
|
||
>
|
||
<AlertDialogContent size="sm">
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Заменить все правила?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Текущие правила будут заменены выбранным пресетом. Это нельзя отменить.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
variant="destructive"
|
||
onClick={() => {
|
||
if (pendingPresetReplaceId) applyPresetReplace(pendingPresetReplaceId)
|
||
setPendingPresetReplaceId(null)
|
||
}}
|
||
>
|
||
Заменить
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
)
|
||
}
|