Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
3194 lines
133 KiB
TypeScript
3194 lines
133 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import {
|
||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
|
||
} from "@/components/ui/sheet"
|
||
import {
|
||
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon, LoaderCircleIcon,
|
||
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
|
||
TimerIcon, WifiOffIcon, TrendingDownIcon,
|
||
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
|
||
Trash2Icon,
|
||
PencilIcon,
|
||
ChevronsUpDownIcon,
|
||
SearchIcon,
|
||
AlertCircleIcon,
|
||
ChevronUpIcon,
|
||
ChevronDownIcon,
|
||
PlugIcon,
|
||
UnplugIcon,
|
||
} from "lucide-react"
|
||
import { Flag, countryName } from "@/components/flag"
|
||
import { cn } from "@/lib/utils"
|
||
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 Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||
return (
|
||
<button role="switch" aria-checked={checked} aria-disabled={disabled} disabled={disabled}
|
||
onClick={() => { if (!disabled) onChange(!checked) }}
|
||
className={cn(
|
||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||
disabled && "opacity-50 pointer-events-none",
|
||
checked ? "bg-primary" : "bg-muted-foreground/30",
|
||
)}>
|
||
<span className={cn(
|
||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0.5",
|
||
)} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function FieldLabel({ children, className }: { children: React.ReactNode; className?: string }) {
|
||
return <p className={cn("text-sm font-medium mb-1.5 leading-none", className)}>{children}</p>
|
||
}
|
||
|
||
function FieldHint({ children }: { children: React.ReactNode }) {
|
||
return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
|
||
}
|
||
|
||
function SeverityDot({ severity }: { severity: AlertSeverity }) {
|
||
return <span className={cn("inline-block size-2 rounded-full shrink-0", SEVERITY_META[severity].dot)} />
|
||
}
|
||
|
||
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
|
||
return (
|
||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap", SEVERITY_META[severity].badge)}>
|
||
{severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ─── alert rule row ───────────────────────────────────────────────────────────
|
||
|
||
function AlertRuleRow({ rule, onToggle, onDelete, onEdit, interactionsDisabled }: {
|
||
rule: AlertRule
|
||
onToggle: (id: string, enabled: boolean) => void
|
||
onDelete: (id: string) => void
|
||
onEdit?: (id: string) => void
|
||
interactionsDisabled?: boolean
|
||
}) {
|
||
const { Icon, iconClass, bg } = TYPE_META[rule.type]
|
||
|
||
return (
|
||
<div className={cn(
|
||
"flex items-center gap-3 px-4 py-3 hover:bg-muted/20 transition-colors group",
|
||
!rule.enabled && "opacity-55",
|
||
)}>
|
||
{/* toggle */}
|
||
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} disabled={interactionsDisabled} />
|
||
|
||
{/* severity dot */}
|
||
<SeverityDot severity={rule.severity} />
|
||
|
||
{/* type icon */}
|
||
<div className={cn("size-7 rounded-md flex items-center justify-center shrink-0", bg)}>
|
||
<Icon className={cn("size-3.5", iconClass)} />
|
||
</div>
|
||
|
||
{/* name + target */}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium truncate">{rule.name}</p>
|
||
<p className="text-[11px] text-muted-foreground truncate">
|
||
{TYPE_META[rule.type].label} ·{" "}
|
||
<span className="font-mono">
|
||
{rule.targets.length > 1 ? `${rule.targets.length} объектов` : rule.target}
|
||
</span>
|
||
{rule.groupId ? (
|
||
<span className="text-muted-foreground/80"> · группа</span>
|
||
) : null}
|
||
{" · "}{rule.condition}
|
||
</p>
|
||
</div>
|
||
|
||
{/* cooldown */}
|
||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||
{rule.cooldown}
|
||
</span>
|
||
{formatConfirmStabilityShort(rule.confirmStabilitySec) ? (
|
||
<span
|
||
className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground shrink-0 max-w-[72px] truncate"
|
||
title={`Подтверждение: ${formatConfirmStabilityShort(rule.confirmStabilitySec)}`}
|
||
>
|
||
⏱ {formatConfirmStabilityShort(rule.confirmStabilitySec)}
|
||
</span>
|
||
) : null}
|
||
|
||
{/* chat override badge */}
|
||
{rule.chatId && (
|
||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground hidden xl:inline shrink-0">
|
||
#{rule.chatId.slice(-6)}
|
||
</span>
|
||
)}
|
||
|
||
{/* last fired */}
|
||
<span className={cn(
|
||
"text-[11px] shrink-0 w-[80px] text-right",
|
||
rule.lastFired ? "text-muted-foreground" : "text-muted-foreground/40",
|
||
)}>
|
||
{rule.lastFired ?? "—"}
|
||
</span>
|
||
|
||
{/* severity badge */}
|
||
<div className="hidden lg:block shrink-0">
|
||
<SeverityBadge severity={rule.severity} />
|
||
</div>
|
||
|
||
{/* actions (visible on hover) */}
|
||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||
{onEdit && (
|
||
<button
|
||
type="button"
|
||
disabled={interactionsDisabled}
|
||
onClick={() => onEdit(rule.id)}
|
||
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40"
|
||
title="Изменить"
|
||
>
|
||
<PencilIcon className="size-3.5" />
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
disabled={interactionsDisabled}
|
||
onClick={() => onDelete(rule.id)}
|
||
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40">
|
||
<Trash2Icon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── telegram config card ─────────────────────────────────────────────────────
|
||
|
||
function TelegramCard({ cfg, onChange, tokenConfigured, liveSaveBusy, onSaveTelegram, onTestTelegram }: {
|
||
cfg: TelegramConfig
|
||
onChange: (c: TelegramConfig) => void
|
||
/** В режиме live: токен в БД есть, в поле не подставляем */
|
||
tokenConfigured?: boolean
|
||
liveSaveBusy?: boolean
|
||
onSaveTelegram?: () => void | Promise<void>
|
||
onTestTelegram?: () => void | Promise<void>
|
||
}) {
|
||
const [showToken, setShowToken] = useState(false)
|
||
const [testing, setTesting] = useState(false)
|
||
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)
|
||
|
||
const handleTest = async () => {
|
||
setTesting(true)
|
||
setTestResult(null)
|
||
try {
|
||
if (onTestTelegram) {
|
||
await onTestTelegram()
|
||
setTestResult("ok")
|
||
} else {
|
||
await new Promise((r) => setTimeout(r, 450))
|
||
setTestResult(cfg.connected ? "ok" : "fail")
|
||
}
|
||
} catch {
|
||
setTestResult("fail")
|
||
} finally {
|
||
setTesting(false)
|
||
setTimeout(() => setTestResult(null), 4000)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-3 pt-4 px-4">
|
||
<CardTitle className="text-sm flex items-center justify-between">
|
||
<span className="flex items-center gap-2">
|
||
<span className="text-base">✈️</span> Telegram
|
||
</span>
|
||
<span className={cn(
|
||
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
|
||
cfg.connected
|
||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||
: "bg-muted text-muted-foreground",
|
||
)}>
|
||
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
|
||
{cfg.connected ? "Подключён" : "Не настроен"}
|
||
</span>
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 space-y-3">
|
||
|
||
{/* bot token */}
|
||
<div>
|
||
<FieldLabel>Bot Token</FieldLabel>
|
||
<div className="flex gap-1.5">
|
||
<Input
|
||
type={showToken ? "text" : "password"}
|
||
value={cfg.token}
|
||
onChange={e => onChange({ ...cfg, token: e.target.value })}
|
||
autoComplete="new-password"
|
||
name="telegram-bot-token"
|
||
className="text-xs font-mono h-8"
|
||
placeholder={tokenConfigured && !cfg.token ? "Токен сохранён в БД — введите новый для замены" : "1234567890:AAF..."}
|
||
/>
|
||
<button
|
||
onClick={() => setShowToken(v => !v)}
|
||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||
</button>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(cfg.token)}
|
||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||
<CopyIcon className="size-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* default chat id */}
|
||
<div>
|
||
<FieldLabel>Chat ID по умолчанию</FieldLabel>
|
||
<Input
|
||
value={cfg.chatId}
|
||
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
|
||
className="text-xs font-mono h-8"
|
||
placeholder="-100xxxxxxxxxx"
|
||
/>
|
||
<p className="text-[10px] text-muted-foreground mt-1">
|
||
Для групп используйте отрицательный ID. Каждое правило может переопределить.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<FieldLabel>ID темы (message_thread_id)</FieldLabel>
|
||
<Input
|
||
value={cfg.messageThreadId}
|
||
onChange={e => onChange({ ...cfg, messageThreadId: e.target.value.replace(/\D/g, "") })}
|
||
className="text-xs font-mono h-8"
|
||
inputMode="numeric"
|
||
placeholder="Напр. 12345 — только для супергрупп с темами"
|
||
/>
|
||
<FieldHint>
|
||
Опционально: номер топика в форум-супергруппе. Пусто — сообщение в общий чат группы.
|
||
</FieldHint>
|
||
</div>
|
||
|
||
{onSaveTelegram && (
|
||
<Button
|
||
size="sm" className="w-full h-8 text-xs"
|
||
disabled={!!liveSaveBusy}
|
||
onClick={() => { void onSaveTelegram() }}
|
||
>
|
||
{liveSaveBusy ? <RefreshCwIcon className="size-3.5 animate-spin" /> : null}
|
||
{liveSaveBusy ? "Сохранение…" : "Сохранить в БД"}
|
||
</Button>
|
||
)}
|
||
|
||
{/* test button */}
|
||
<Button
|
||
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
|
||
onClick={() => { void handleTest() }} disabled={testing}>
|
||
{testing ? (
|
||
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||
) : testResult === "ok" ? (
|
||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||
) : testResult === "fail" ? (
|
||
<XIcon className="size-3.5 text-destructive" />
|
||
) : (
|
||
<SendIcon className="size-3.5" />
|
||
)}
|
||
{testing ? "Отправка…"
|
||
: testResult === "ok" ? "Сообщение отправлено"
|
||
: testResult === "fail" ? "Ошибка отправки"
|
||
: "Отправить тестовое сообщение"}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
// ─── history card ─────────────────────────────────────────────────────────────
|
||
|
||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||
return (
|
||
<Card>
|
||
<CardHeader className="pb-2 pt-4 px-4">
|
||
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4">
|
||
{entries.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
||
) : (
|
||
<div className="flex flex-col gap-2.5">
|
||
{entries.map(e => (
|
||
<div key={e.id} className="flex gap-2.5 items-start">
|
||
<SeverityDot severity={e.severity} />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[11px] font-medium leading-tight truncate">{e.ruleName}</p>
|
||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-2">{e.message}</p>
|
||
</div>
|
||
<div className="text-right shrink-0">
|
||
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
|
||
<span className={cn(
|
||
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
|
||
e.sent
|
||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||
)}>
|
||
{e.sent ? "✓ отправлено" : "✗ ошибка"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const SERVER_ROLES_RU: Record<string, string> = {
|
||
"jump-host": "Транзитный узел",
|
||
"exit-node": "Выходной узел",
|
||
"home-router": "Домашний роутер",
|
||
}
|
||
|
||
function serverRowLabel(s: AlertsServerRow): string {
|
||
return (s.name?.trim() || s.host || "").trim()
|
||
}
|
||
|
||
/** Типы, где «объект» — сервер из каталога: показываем развёрнутые карточки. */
|
||
const OBJECT_TYPES_WITH_SERVER_CARDS: AlertType[] = ["server", "gre-client", "traffic"]
|
||
|
||
/** Карточка сервера: поиск, флаг страны, имя, host, площадка, роль (как GRE-карточки). */
|
||
function ServerObjectCardPicker({
|
||
label,
|
||
rows,
|
||
selected,
|
||
onToggle,
|
||
hideLabel,
|
||
/** Родитель задаёт высоту и скролл (блок в шите) */
|
||
listUnconstrained,
|
||
}: {
|
||
label: string
|
||
rows: AlertsServerRow[]
|
||
selected: string[]
|
||
onToggle: (serverTitle: string) => void
|
||
/** Один общий заголовок снаружи (блок «шагов») */
|
||
hideLabel?: boolean
|
||
listUnconstrained?: boolean
|
||
}) {
|
||
const [query, setQuery] = useState("")
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
setQuery("")
|
||
})
|
||
}, [rows])
|
||
|
||
const filteredRows = useMemo(() => {
|
||
const s = query.trim().toLowerCase()
|
||
if (!s) return rows
|
||
return rows.filter((row) => {
|
||
const cc = iso2CountryCode(row.country)
|
||
const blob = [
|
||
serverRowLabel(row),
|
||
row.host,
|
||
row.site,
|
||
row.country,
|
||
cc ? countryName(cc) : "",
|
||
SERVER_ROLES_RU[row.type] ?? row.type,
|
||
]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
return blob.includes(s)
|
||
})
|
||
}, [rows, query])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5 min-w-0">
|
||
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
|
||
{rows.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground leading-snug">Нет серверов для выбора</p>
|
||
) : (
|
||
<>
|
||
<div className="relative shrink-0">
|
||
<SearchIcon
|
||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden
|
||
/>
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
autoComplete="off"
|
||
name="alert-server-search"
|
||
placeholder="Поиск: сервер, площадка, IP, роль…"
|
||
className="h-8 pl-8 pr-2 text-xs"
|
||
aria-label="Поиск по списку серверов"
|
||
/>
|
||
</div>
|
||
<div
|
||
role="listbox"
|
||
aria-label={label}
|
||
className={cn(
|
||
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
|
||
listUnconstrained
|
||
? "overflow-visible"
|
||
: "max-h-[min(40vh,16rem)] overflow-y-auto",
|
||
)}
|
||
>
|
||
{filteredRows.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
|
||
) : null}
|
||
{filteredRows.map((s) => {
|
||
const title = serverRowLabel(s)
|
||
const active = selected.includes(title)
|
||
const loc = [s.site, s.country].filter(Boolean).join(" · ")
|
||
const cc = iso2CountryCode(s.country)
|
||
return (
|
||
<button
|
||
key={`${title}\0${s.host}`}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={active}
|
||
onClick={() => onToggle(title)}
|
||
className={cn(
|
||
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
|
||
active
|
||
? "border-primary bg-primary/8 ring-1 ring-primary/30"
|
||
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
||
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground mt-px" aria-hidden />
|
||
<div className="min-w-0 flex-1 space-y-0.5">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
{cc ? (
|
||
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
|
||
) : null}
|
||
<span className="text-[11px] font-semibold text-foreground truncate">{title}</span>
|
||
</div>
|
||
<p className="text-[10px] font-mono text-muted-foreground truncate">{s.host || "—"}</p>
|
||
{loc ? (
|
||
<p className="text-[10px] text-muted-foreground/90 truncate">{loc}</p>
|
||
) : null}
|
||
<p className="text-[9px] text-muted-foreground/80">
|
||
{SERVER_ROLES_RU[s.type] ?? s.type}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{active ? (
|
||
<CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-px" aria-hidden />
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Карточки GRE: страна/площадка, роутер, имя интерфейса, remote, статус. */
|
||
function GreTunnelObjectCardPicker({
|
||
label,
|
||
rows,
|
||
selected,
|
||
onToggle,
|
||
hideLabel,
|
||
listUnconstrained,
|
||
}: {
|
||
label: string
|
||
rows: AlertsGreTunnelRow[]
|
||
selected: string[]
|
||
onToggle: (targetLabel: string) => void
|
||
hideLabel?: boolean
|
||
listUnconstrained?: boolean
|
||
}) {
|
||
const [query, setQuery] = useState("")
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
setQuery("")
|
||
})
|
||
}, [rows])
|
||
|
||
const filteredRows = useMemo(() => {
|
||
const s = query.trim().toLowerCase()
|
||
if (!s) return rows
|
||
return rows.filter((r) => {
|
||
const cc = iso2CountryCode(r.country)
|
||
const hay = [
|
||
r.tunnelName,
|
||
r.routerName,
|
||
r.site,
|
||
r.country,
|
||
r.remoteAddress,
|
||
r.targetLabel,
|
||
r.comment ?? "",
|
||
cc ? countryName(cc) : "",
|
||
]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
return hay.includes(s)
|
||
})
|
||
}, [rows, query])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5 min-w-0">
|
||
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
|
||
{rows.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground leading-snug">Нет туннелей для выбора</p>
|
||
) : (
|
||
<>
|
||
<div className="relative shrink-0">
|
||
<SearchIcon
|
||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden
|
||
/>
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
autoComplete="off"
|
||
name="alert-gre-search"
|
||
placeholder="Поиск: имя, роутер, IP, площадка…"
|
||
className="h-8 pl-8 pr-2 text-xs"
|
||
aria-label="Поиск по списку GRE-туннелей"
|
||
/>
|
||
</div>
|
||
<div
|
||
role="listbox"
|
||
aria-label={label}
|
||
className={cn(
|
||
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
|
||
listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto",
|
||
)}
|
||
>
|
||
{filteredRows.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
|
||
) : null}
|
||
{filteredRows.map((r) => {
|
||
const active = selected.includes(r.targetLabel)
|
||
const st = GRE_CARD_STATUS[r.status] ?? { label: String(r.status), dot: "bg-muted-foreground" }
|
||
const cc = iso2CountryCode(r.country)
|
||
const siteLine =
|
||
r.site && r.site !== "—"
|
||
? r.site
|
||
: cc
|
||
? countryName(cc)
|
||
: "—"
|
||
return (
|
||
<button
|
||
key={`${r.targetLabel}\0${r.remoteAddress}`}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={active}
|
||
onClick={() => onToggle(r.targetLabel)}
|
||
className={cn(
|
||
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
|
||
active
|
||
? "border-primary bg-primary/8 ring-1 ring-primary/30"
|
||
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-1.5 w-full min-w-0">
|
||
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
||
<CableIcon className="size-3.5 shrink-0 text-violet-500/90 mt-px" aria-hidden />
|
||
<div className="min-w-0 flex-1 space-y-0.5">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
{cc ? (
|
||
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
|
||
) : null}
|
||
<span className="text-[11px] font-medium text-foreground truncate">{siteLine}</span>
|
||
</div>
|
||
<p className="text-[11px] text-foreground truncate">
|
||
<span className="text-muted-foreground/85">Роутер</span>{" "}
|
||
<span className="font-medium">{r.routerName}</span>
|
||
</p>
|
||
<p className="text-[11px] truncate">
|
||
<span className="text-muted-foreground/85">Название</span>{" "}
|
||
<span className="font-mono font-semibold text-foreground">{r.tunnelName}</span>
|
||
</p>
|
||
{r.remoteAddress && r.remoteAddress !== "—" ? (
|
||
<p className="text-[10px] font-mono text-muted-foreground/90 truncate">
|
||
{r.remoteAddress}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<span className="flex items-center gap-1.5 shrink-0 text-[9px] text-muted-foreground pt-px">
|
||
<span className="flex items-center gap-1">
|
||
<span className={cn("size-1.5 rounded-full shrink-0", st.dot)} aria-hidden />
|
||
<span className="whitespace-nowrap leading-none">{st.label}</span>
|
||
</span>
|
||
{active ? (
|
||
<CheckIcon className="size-4 shrink-0 text-primary opacity-70" aria-hidden />
|
||
) : null}
|
||
</span>
|
||
</div>
|
||
{r.comment ? (
|
||
<p className="text-[9px] text-muted-foreground/80 line-clamp-2 mt-1 pl-5 border-t border-border/30 pt-1 leading-snug">
|
||
{r.comment}
|
||
</p>
|
||
) : null}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Карточки объекта для типов со строковым списком (BGP, префиксы, RTT/loss и т.д.): поиск + иконка типа. */
|
||
function StringOptionCardPicker({
|
||
label,
|
||
options,
|
||
selected,
|
||
onToggle,
|
||
hideLabel,
|
||
listUnconstrained,
|
||
ruleType,
|
||
}: {
|
||
label: string
|
||
options: string[]
|
||
selected: string[]
|
||
onToggle: (opt: string) => void
|
||
hideLabel?: boolean
|
||
listUnconstrained?: boolean
|
||
ruleType: AlertType
|
||
}) {
|
||
const [query, setQuery] = useState("")
|
||
const m = TYPE_META[ruleType]
|
||
const Icon = m.Icon
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
setQuery("")
|
||
})
|
||
}, [options])
|
||
|
||
const filtered = useMemo(() => {
|
||
const s = query.trim().toLowerCase()
|
||
if (!s) return options
|
||
return options.filter((o) => o.toLowerCase().includes(s))
|
||
}, [options, query])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5 min-w-0">
|
||
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
|
||
{options.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground leading-snug">Нет объектов для выбора</p>
|
||
) : (
|
||
<>
|
||
<div className="relative shrink-0">
|
||
<SearchIcon
|
||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden
|
||
/>
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
autoComplete="off"
|
||
name="alert-target-search"
|
||
placeholder="Поиск по списку объектов…"
|
||
className="h-8 pl-8 pr-2 text-xs"
|
||
aria-label="Поиск по списку объектов"
|
||
/>
|
||
</div>
|
||
<div
|
||
role="listbox"
|
||
aria-label={label}
|
||
className={cn(
|
||
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
|
||
listUnconstrained ? "overflow-visible" : "max-h-[min(40vh,16rem)] overflow-y-auto",
|
||
)}
|
||
>
|
||
{filtered.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
|
||
) : null}
|
||
{filtered.map((opt) => {
|
||
const active = selected.includes(opt)
|
||
const hint = discreteTargetSubtitle(ruleType, opt)
|
||
return (
|
||
<button
|
||
key={opt}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={active}
|
||
onClick={() => onToggle(opt)}
|
||
className={cn(
|
||
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
|
||
active
|
||
? "border-primary bg-primary/8 ring-1 ring-primary/30"
|
||
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||
<div className="flex items-start gap-1.5 min-w-0 flex-1">
|
||
<Icon className={cn("size-3.5 shrink-0 mt-px", m.iconClass)} aria-hidden />
|
||
<div className="min-w-0 flex-1 space-y-0.5">
|
||
<p className="text-[11px] font-semibold text-foreground break-words">{opt}</p>
|
||
{hint ? (
|
||
<p className="text-[10px] text-muted-foreground/90 truncate">{hint}</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{active ? (
|
||
<CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-px" aria-hidden />
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Выбор условия: как объекты — несколько карточек, OR по условиям. */
|
||
function ConditionOptionCardPicker({
|
||
label,
|
||
conditions,
|
||
selected,
|
||
onToggle,
|
||
hideLabel,
|
||
listUnconstrained,
|
||
ruleType,
|
||
}: {
|
||
label: string
|
||
conditions: string[]
|
||
selected: string[]
|
||
onToggle: (v: string) => void
|
||
hideLabel?: boolean
|
||
listUnconstrained?: boolean
|
||
ruleType: AlertType
|
||
}) {
|
||
const [query, setQuery] = useState("")
|
||
const m = TYPE_META[ruleType]
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
setQuery("")
|
||
})
|
||
}, [conditions])
|
||
|
||
const filtered = useMemo(() => {
|
||
const s = query.trim().toLowerCase()
|
||
if (!s) return conditions
|
||
return conditions.filter((o) => o.toLowerCase().includes(s))
|
||
}, [conditions, query])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1.5 min-w-0">
|
||
{!hideLabel ? <FieldLabel>{label}</FieldLabel> : null}
|
||
{conditions.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground leading-snug">Нет условий для этого типа</p>
|
||
) : (
|
||
<>
|
||
<div className="relative shrink-0">
|
||
<SearchIcon
|
||
className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||
aria-hidden
|
||
/>
|
||
<Input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
autoComplete="off"
|
||
name="alert-condition-search"
|
||
placeholder="Поиск по вариантам условия…"
|
||
className="h-8 pl-8 pr-2 text-xs"
|
||
aria-label="Поиск по условиям"
|
||
/>
|
||
</div>
|
||
<div
|
||
role="listbox"
|
||
aria-label={label}
|
||
aria-multiselectable
|
||
className={cn(
|
||
"flex flex-col gap-1 overscroll-contain pr-1 -mr-1",
|
||
listUnconstrained ? "overflow-visible" : "max-h-[min(36vh,14rem)] overflow-y-auto",
|
||
)}
|
||
>
|
||
{filtered.length === 0 ? (
|
||
<p className="text-[10px] text-muted-foreground py-2 text-center">Ничего не найдено</p>
|
||
) : null}
|
||
{filtered.map((opt) => {
|
||
const active = selected.includes(opt)
|
||
const { Icon: CondIcon, className: condIconCls } = conditionLeadingIcon(opt)
|
||
return (
|
||
<button
|
||
key={opt}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={active}
|
||
onClick={() => onToggle(opt)}
|
||
className={cn(
|
||
"rounded-md border px-2.5 py-1.5 text-left transition-all w-full min-w-0 leading-tight",
|
||
active
|
||
? "border-primary bg-primary/8 ring-1 ring-primary/30"
|
||
: "border-border hover:bg-muted/40 hover:border-muted-foreground/35",
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||
<div
|
||
className={cn(
|
||
"flex h-7 min-w-[2.125rem] shrink-0 items-center justify-center gap-0.5 rounded-md px-1",
|
||
m.bg,
|
||
)}
|
||
>
|
||
<m.Icon className={cn("size-3 shrink-0", m.iconClass)} aria-hidden />
|
||
<CondIcon className={cn("size-3 shrink-0", condIconCls)} aria-hidden />
|
||
</div>
|
||
<div className="min-w-0 flex-1 space-y-0.5">
|
||
<p className="text-[11px] font-semibold text-foreground break-words">{opt}</p>
|
||
<p className="text-[9px] text-muted-foreground/85 leading-tight">{m.label}</p>
|
||
</div>
|
||
{active ? (
|
||
<CheckIcon className="size-4 shrink-0 text-primary opacity-70 mt-0.5" aria-hidden />
|
||
) : null}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Свернутый выбранный объект — развернуть список снова (без второй рамки: внешний контейнер уже обводит блок). */
|
||
function ObjectPickSummaryRow({
|
||
ruleType,
|
||
targets,
|
||
server,
|
||
greTunnel,
|
||
onExpand,
|
||
}: {
|
||
ruleType: AlertType
|
||
targets: string[]
|
||
server?: AlertsServerRow
|
||
greTunnel?: AlertsGreTunnelRow
|
||
onExpand: () => void
|
||
}) {
|
||
const target = targets[0] ?? ""
|
||
const multiHint = targets.length > 1 ? ` (+${targets.length - 1})` : ""
|
||
|
||
if (greTunnel) {
|
||
const cc = iso2CountryCode(greTunnel.country)
|
||
const siteLine =
|
||
greTunnel.site && greTunnel.site !== "—"
|
||
? greTunnel.site
|
||
: cc
|
||
? countryName(cc)
|
||
: "—"
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onExpand}
|
||
className={cn(
|
||
"group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
|
||
"bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
)}
|
||
>
|
||
<CableIcon className="size-3.5 shrink-0 text-violet-500/85" aria-hidden />
|
||
<div className="min-w-0 flex-1 text-left space-y-0.5">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
{cc ? (
|
||
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
|
||
) : null}
|
||
<p className="text-[11px] font-medium text-foreground truncate">{siteLine}</p>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground truncate">
|
||
<span className="text-foreground font-medium">{greTunnel.routerName}</span>
|
||
<span className="mx-1 text-muted-foreground/45">·</span>
|
||
<span className="font-mono text-foreground">{greTunnel.tunnelName}</span>
|
||
</p>
|
||
</div>
|
||
<span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
|
||
<ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
|
||
Изменить
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
if (server) {
|
||
const cc = iso2CountryCode(server.country)
|
||
const line2 = (() => {
|
||
const h = (server.host ?? "").trim()
|
||
if (h && h !== target.trim()) return { text: h, mono: true as const }
|
||
const loc = [server.site, server.country].filter(Boolean).join(" · ")
|
||
if (loc) return { text: loc, mono: false as const }
|
||
const role = SERVER_ROLES_RU[server.type] ?? server.type
|
||
if (role) return { text: role, mono: false as const }
|
||
return null
|
||
})()
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onExpand}
|
||
className={cn(
|
||
"group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
|
||
"bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
)}
|
||
>
|
||
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden />
|
||
<div className="min-w-0 flex-1 text-left space-y-0.5">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
{cc ? (
|
||
<Flag code={cc} size={16} className="shrink-0 ring-1 ring-border/40 rounded-[2px]" />
|
||
) : null}
|
||
<p className="text-[12px] font-medium text-foreground truncate">{target}{multiHint}</p>
|
||
</div>
|
||
{line2 ? (
|
||
<p
|
||
className={cn(
|
||
"text-[11px] text-muted-foreground truncate",
|
||
line2.mono && "font-mono",
|
||
)}
|
||
>
|
||
{line2.text}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
|
||
<ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
|
||
Изменить
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
const m = TYPE_META[ruleType]
|
||
const Icon = m.Icon
|
||
const sub = discreteTargetSubtitle(ruleType, target)
|
||
const joined = targets.length > 1 ? targets.join(", ") : target
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onExpand}
|
||
className={cn(
|
||
"group flex w-full min-w-0 items-center gap-2 rounded-md py-1.5 text-left transition-colors leading-tight",
|
||
"bg-muted/25 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
)}
|
||
>
|
||
<Icon className={cn("size-3.5 shrink-0", m.iconClass)} aria-hidden />
|
||
<div className="min-w-0 flex-1 text-left space-y-0.5">
|
||
<p className="text-[12px] font-medium text-foreground break-words">{joined}</p>
|
||
{sub ? (
|
||
<p className="text-[11px] text-muted-foreground truncate">{sub}</p>
|
||
) : null}
|
||
</div>
|
||
<span className="flex items-center gap-1 text-[9px] font-medium text-muted-foreground group-hover:text-foreground shrink-0">
|
||
<ChevronsUpDownIcon className="size-3 opacity-70" aria-hidden />
|
||
Изменить
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ─── add rule sheet ───────────────────────────────────────────────────────────
|
||
|
||
const BLANK_FORM: RuleFormSnapshot = {
|
||
name: "",
|
||
type: "server",
|
||
targets: [],
|
||
groupId: null,
|
||
conditions: [],
|
||
threshold: "",
|
||
severity: "warning",
|
||
cooldown: "5м",
|
||
confirmStabilitySec: null,
|
||
chatId: "",
|
||
}
|
||
|
||
type RuleTelegramTestPayload = {
|
||
name: string
|
||
targets: string[]
|
||
conditionLine: string
|
||
severity: AlertSeverity
|
||
cooldown: AlertCooldown
|
||
ruleChatId: string
|
||
}
|
||
|
||
function AddRuleSheet({
|
||
open,
|
||
onClose,
|
||
onSave,
|
||
typeTargets,
|
||
isLive,
|
||
serversCatalog,
|
||
greTunnelCatalog,
|
||
groups,
|
||
initialForm,
|
||
editRuleId,
|
||
onTestRuleTelegram,
|
||
testTelegramDisabled,
|
||
}: {
|
||
open: boolean
|
||
onClose: () => void
|
||
/** editRuleId задан — обновление существующего правила */
|
||
onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">, editRuleId: string | null) => void
|
||
typeTargets: Record<AlertType, string[]>
|
||
isLive: boolean
|
||
serversCatalog: AlertsServerRow[]
|
||
greTunnelCatalog: AlertsGreTunnelRow[]
|
||
groups: AlertGroup[]
|
||
initialForm: RuleFormSnapshot | null
|
||
editRuleId: string | null
|
||
/** Live: отправка тестового сообщения с текстом как у сохранённого правила (без записи в БД). */
|
||
onTestRuleTelegram?: (payload: RuleTelegramTestPayload) => Promise<void>
|
||
/** true — кнопка теста неактивна (например, бэкенд недоступен). */
|
||
testTelegramDisabled?: boolean
|
||
}) {
|
||
const [form, setForm] = useState<RuleFormSnapshot>(() => ({
|
||
...BLANK_FORM,
|
||
...(initialForm ?? {}),
|
||
}))
|
||
const nameTouchedRef = useRef(Boolean(editRuleId))
|
||
/** Список объектов не показываем сразу — после фокуса/клика по другому полю шита. Редактирование с выбранными объектами — сразу. */
|
||
const [objectPickerRevealed, setObjectPickerRevealed] = useState(
|
||
() => Boolean(editRuleId && (initialForm?.targets?.length ?? 0) > 0),
|
||
)
|
||
const revealObjectPicker = useCallback(() => {
|
||
setObjectPickerRevealed(true)
|
||
}, [])
|
||
/** Для списка объектов: после выбора сворачиваем список, ниже показываем условия. */
|
||
const [objectExpanded, setObjectExpanded] = useState(
|
||
() => !(initialForm?.targets?.length),
|
||
)
|
||
const [testTelegramBusy, setTestTelegramBusy] = useState(false)
|
||
|
||
const 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 [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
|
||
const [sheetEditId, setSheetEditId] = useState<string | null>(null)
|
||
const [sheetKey, setSheetKey] = useState(0)
|
||
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all")
|
||
const [onlyActive, setOnlyActive] = useState(false)
|
||
const [search, setSearch] = useState("")
|
||
|
||
const [loadBusy, setLoadBusy] = useState(false)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [rulesSaveBusy, setRulesSaveBusy] = useState(false)
|
||
const [telegramSaveBusy, setTelegramSaveBusy] = useState(false)
|
||
/** Пресеты и группы ANY/ALL — по умолчанию свёрнуты, чтобы не занимать место */
|
||
const [extrasExpanded, setExtrasExpanded] = useState(false)
|
||
const [hintsExpanded, setHintsExpanded] = useState(false)
|
||
|
||
const typeTargets = useMemo(() => {
|
||
if (!isLive) return TYPE_TARGETS
|
||
if (!meta) return emptyTypeTargets()
|
||
return buildLiveTypeTargets(meta)
|
||
}, [isLive, meta])
|
||
|
||
const serversCatalog = useMemo((): AlertsServerRow[] => {
|
||
if (isLive && meta?.serversDetail?.length) return meta.serversDetail
|
||
if (!isLive) {
|
||
return TYPE_TARGETS.server.map((name, i) => ({
|
||
name,
|
||
host: `192.0.2.${(i % 220) + 10}`,
|
||
site: ["MSK", "SPB", "FRA", "AMS", "SGP"][i % 5] ?? "",
|
||
country: ["RU", "RU", "DE", "NL", "SG"][i % 5] ?? "",
|
||
type: (["jump-host", "exit-node", "home-router"] as const)[i % 3] as string,
|
||
}))
|
||
}
|
||
return []
|
||
}, [isLive, meta])
|
||
|
||
const greTunnelCatalog = useMemo((): AlertsGreTunnelRow[] => {
|
||
if (isLive && meta?.greTunnelDetail?.length) return meta.greTunnelDetail
|
||
if (!isLive) return demoGreTunnelCatalog()
|
||
return meta?.greTunnelDetail ?? []
|
||
}, [isLive, meta])
|
||
|
||
const load = useCallback(async () => {
|
||
if (!isLive) {
|
||
setRules(INIT_RULES)
|
||
setGroups(INIT_GROUPS)
|
||
setHistory(INIT_HISTORY)
|
||
setTg(INIT_TG)
|
||
setTokenConfigured(false)
|
||
setMeta(null)
|
||
setLoadError(null)
|
||
return
|
||
}
|
||
setLoadBusy(true)
|
||
setLoadError(null)
|
||
try {
|
||
const data = await apiFetch<{
|
||
telegram: {
|
||
chatId: string
|
||
tokenConfigured: boolean
|
||
connected: boolean
|
||
messageThreadId: number | null
|
||
}
|
||
groups?: AlertsApiGroup[]
|
||
rules: AlertsApiRule[]
|
||
history: AlertsApiHistoryEntry[]
|
||
meta: AlertsMeta
|
||
}>("/api/alerts")
|
||
setTokenConfigured(data.telegram.tokenConfigured)
|
||
setTg({
|
||
token: "",
|
||
chatId: data.telegram.chatId ?? "",
|
||
messageThreadId:
|
||
data.telegram.messageThreadId != null ? String(data.telegram.messageThreadId) : "",
|
||
connected: data.telegram.connected,
|
||
})
|
||
setRules(mapApiRules(data.rules))
|
||
setGroups(mapApiGroups(data.groups ?? []))
|
||
setHistory(mapApiHistory(data.history))
|
||
let mergedMeta: AlertsMeta = data.meta
|
||
try {
|
||
const bgpRows = await apiFetch<BgpSessionListRow[]>("/api/bgp/sessions")
|
||
const bgpPeers = uniqStrings(
|
||
bgpRows.map((s) => `${String(s.serverName ?? "").trim()}: ${String(s.name ?? "").trim()}`.trim()),
|
||
)
|
||
mergedMeta = { ...data.meta, bgpPeers }
|
||
} catch {
|
||
mergedMeta = { ...data.meta, bgpPeers: [] }
|
||
}
|
||
try {
|
||
const [srvList, greRes] = await Promise.all([
|
||
apiFetch<
|
||
Array<{ id: number; name: string; site?: string; country?: string; host?: string }>
|
||
>("/api/servers"),
|
||
apiFetch<{
|
||
tunnels: Array<{
|
||
id?: string
|
||
name: string
|
||
serverId: string
|
||
remoteAddress?: string
|
||
status?: string
|
||
comment?: string
|
||
}>
|
||
}>("/api/filters/gre-tunnels"),
|
||
])
|
||
const sidToSrv = new Map(
|
||
srvList.map((s) => [
|
||
String(s.id),
|
||
{
|
||
name: String(s.name ?? "").trim() || String(s.id),
|
||
site: String(s.site ?? "").trim(),
|
||
country: String(s.country ?? "").trim(),
|
||
host: String(s.host ?? "").trim(),
|
||
},
|
||
]),
|
||
)
|
||
const greTunnelDetail: AlertsGreTunnelRow[] = greRes.tunnels.map((t) => {
|
||
const srv = sidToSrv.get(String(t.serverId))
|
||
const sn = srv?.name ?? String(t.serverId)
|
||
const tn = (String(t.name ?? "").trim() || String(t.id ?? "").trim() || "gre").trim()
|
||
const label = `${tn} / ${sn}`.trim()
|
||
const rawSt = (t.status ?? "down").toLowerCase()
|
||
const status: AlertsGreTunnelRow["status"] =
|
||
rawSt === "up" || rawSt === "down" || rawSt === "degraded" ? rawSt : "degraded"
|
||
return {
|
||
targetLabel: label,
|
||
tunnelName: tn,
|
||
routerName: sn,
|
||
country: srv?.country ? srv.country : "—",
|
||
site: srv?.site ? srv.site : "—",
|
||
remoteAddress: (String(t.remoteAddress ?? "").trim() || "—"),
|
||
status,
|
||
comment: (t.comment ?? "").trim() || undefined,
|
||
}
|
||
})
|
||
const greTunnelTargets = uniqStrings(greTunnelDetail.map((r) => r.targetLabel))
|
||
mergedMeta = { ...mergedMeta, greTunnelTargets, greTunnelDetail }
|
||
} catch {
|
||
mergedMeta = { ...mergedMeta, greTunnelTargets: [], greTunnelDetail: [] }
|
||
}
|
||
setMeta(mergedMeta)
|
||
} catch (e) {
|
||
setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||
} finally {
|
||
setLoadBusy(false)
|
||
}
|
||
}, [apiFetch, isLive])
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
void load()
|
||
})
|
||
}, [load])
|
||
|
||
const flushAlertsConfig = useCallback(async (nextRules: AlertRule[], nextGroups: AlertGroup[]) => {
|
||
setRules(nextRules)
|
||
setGroups(nextGroups)
|
||
if (!isLive) return
|
||
setRulesSaveBusy(true)
|
||
setLoadError(null)
|
||
try {
|
||
const res = await apiFetch<{ rules: AlertsApiRule[]; groups: AlertsApiGroup[] }>("/api/alerts/rules", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
rules: nextRules.map(toApiRuleBody),
|
||
groups: nextGroups.map(toApiGroupBody),
|
||
}),
|
||
})
|
||
setRules(mapApiRules(res.rules))
|
||
setGroups(mapApiGroups(res.groups ?? []))
|
||
} catch (e) {
|
||
setLoadError(e instanceof Error ? e.message : "Не удалось сохранить правила и группы")
|
||
await load()
|
||
} finally {
|
||
setRulesSaveBusy(false)
|
||
}
|
||
}, [apiFetch, isLive, load])
|
||
|
||
const saveTelegram = useCallback(async () => {
|
||
if (!isLive) return
|
||
const threadRaw = tg.messageThreadId.trim()
|
||
let messageThreadId: number | null = null
|
||
if (threadRaw) {
|
||
const n = Number.parseInt(threadRaw, 10)
|
||
if (!Number.isFinite(n) || n < 1) {
|
||
setLoadError("ID темы: укажите положительное целое число или оставьте поле пустым")
|
||
return
|
||
}
|
||
messageThreadId = n
|
||
}
|
||
setTelegramSaveBusy(true)
|
||
setLoadError(null)
|
||
try {
|
||
const res = await apiFetch<{
|
||
telegram: {
|
||
chatId: string
|
||
tokenConfigured: boolean
|
||
connected: boolean
|
||
messageThreadId: number | null
|
||
}
|
||
}>(
|
||
"/api/alerts/telegram",
|
||
{
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
chatId: tg.chatId,
|
||
messageThreadId,
|
||
...(tg.token.trim() ? { token: tg.token.trim() } : {}),
|
||
}),
|
||
},
|
||
)
|
||
setTokenConfigured(res.telegram.tokenConfigured)
|
||
setTg({
|
||
token: "",
|
||
chatId: res.telegram.chatId ?? "",
|
||
messageThreadId:
|
||
res.telegram.messageThreadId != null ? String(res.telegram.messageThreadId) : "",
|
||
connected: res.telegram.connected,
|
||
})
|
||
} catch (e) {
|
||
setLoadError(e instanceof Error ? e.message : "Не удалось сохранить Telegram")
|
||
} finally {
|
||
setTelegramSaveBusy(false)
|
||
}
|
||
}, [apiFetch, isLive, tg.chatId, tg.token, tg.messageThreadId])
|
||
|
||
const testTelegram = useCallback(async () => {
|
||
if (!isLive) return
|
||
const threadRaw = tg.messageThreadId.trim()
|
||
const messageThreadId =
|
||
threadRaw ? Number.parseInt(threadRaw, 10) : undefined
|
||
await apiFetch("/api/alerts/telegram/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
token: tg.token.trim() || undefined,
|
||
chatId: tg.chatId.trim() || undefined,
|
||
...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
|
||
? { messageThreadId }
|
||
: {}),
|
||
}),
|
||
})
|
||
}, [apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId])
|
||
|
||
const testRuleTelegramFromForm = useCallback(
|
||
async (p: RuleTelegramTestPayload) => {
|
||
if (!isLive) return
|
||
const threadRaw = tg.messageThreadId.trim()
|
||
const messageThreadId =
|
||
threadRaw && Number.isFinite(Number.parseInt(threadRaw, 10))
|
||
? Number.parseInt(threadRaw, 10)
|
||
: undefined
|
||
await apiFetch("/api/alerts/telegram/test", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
token: tg.token.trim() || undefined,
|
||
chatId: p.ruleChatId.trim() || tg.chatId.trim() || undefined,
|
||
...(messageThreadId != null && Number.isFinite(messageThreadId) && messageThreadId >= 1
|
||
? { messageThreadId }
|
||
: {}),
|
||
rulePreview: {
|
||
name: p.name,
|
||
targets: p.targets,
|
||
conditionLine: p.conditionLine,
|
||
severity: p.severity,
|
||
cooldown: p.cooldown,
|
||
},
|
||
}),
|
||
})
|
||
},
|
||
[apiFetch, isLive, tg.token, tg.chatId, tg.messageThreadId],
|
||
)
|
||
|
||
const serverNamesForPresets = useMemo(() => {
|
||
if (isLive && meta?.servers?.length) return meta.servers
|
||
return TYPE_TARGETS.server
|
||
}, [isLive, meta])
|
||
|
||
const applyPresetAppend = useCallback(
|
||
(presetId: string) => {
|
||
const def = ALERT_PRESETS.find((p) => p.id === presetId)
|
||
if (!def) return
|
||
const built = def.build(serverNamesForPresets)
|
||
if (built.length === 0) return
|
||
const newRows: AlertRule[] = built.map((b) => ({
|
||
...b,
|
||
id:
|
||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||
? crypto.randomUUID()
|
||
: `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
enabled: true,
|
||
lastFired: null,
|
||
}))
|
||
void flushAlertsConfig([...rules, ...newRows], groups)
|
||
},
|
||
[serverNamesForPresets, rules, groups, flushAlertsConfig],
|
||
)
|
||
|
||
const applyPresetReplace = useCallback(
|
||
(presetId: string) => {
|
||
if (typeof window !== "undefined" && !window.confirm("Заменить все текущие правила выбранным пресетом?")) {
|
||
return
|
||
}
|
||
const def = ALERT_PRESETS.find((p) => p.id === presetId)
|
||
if (!def) return
|
||
const built = def.build(serverNamesForPresets)
|
||
const newRows: AlertRule[] = built.map((b) => ({
|
||
...b,
|
||
id:
|
||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||
? crypto.randomUUID()
|
||
: `r${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
enabled: true,
|
||
lastFired: null,
|
||
}))
|
||
void flushAlertsConfig(newRows, groups)
|
||
},
|
||
[serverNamesForPresets, groups, flushAlertsConfig],
|
||
)
|
||
|
||
const addGroup = useCallback(() => {
|
||
const g: AlertGroup = {
|
||
id:
|
||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||
? crypto.randomUUID()
|
||
: `grp-${Date.now()}`,
|
||
name: "Новая группа",
|
||
combineMode: "any",
|
||
enabled: true,
|
||
cooldownOverride: null,
|
||
}
|
||
void flushAlertsConfig(rules, [...groups, g])
|
||
}, [rules, groups, flushAlertsConfig])
|
||
|
||
const patchGroup = useCallback(
|
||
(id: string, patch: Partial<AlertGroup>) => {
|
||
void flushAlertsConfig(
|
||
rules,
|
||
groups.map((x) => (x.id === id ? { ...x, ...patch } : x)),
|
||
)
|
||
},
|
||
[rules, groups, flushAlertsConfig],
|
||
)
|
||
|
||
const removeGroup = useCallback(
|
||
(id: string) => {
|
||
const nextRules = rules.map((r) => (r.groupId === id ? { ...r, groupId: null } : r))
|
||
void flushAlertsConfig(nextRules, groups.filter((g) => g.id !== id))
|
||
},
|
||
[rules, groups, flushAlertsConfig],
|
||
)
|
||
|
||
const total = rules.length
|
||
const active = rules.filter(r => r.enabled).length
|
||
const critical = rules.filter(r => r.severity === "critical").length
|
||
const warning = rules.filter(r => r.severity === "warning").length
|
||
const info = rules.filter(r => r.severity === "info").length
|
||
|
||
const filteredRules = useMemo(() => rules.filter(r => {
|
||
if (sevFilter !== "all" && r.severity !== sevFilter) return false
|
||
if (onlyActive && !r.enabled) return false
|
||
const blob =
|
||
`${r.name} ${r.target} ${r.targets.join(" ")} ${r.condition} ${r.conditions.join(" ")}`.toLowerCase()
|
||
if (search && !blob.includes(search.toLowerCase())) return false
|
||
return true
|
||
}), [rules, sevFilter, onlyActive, search])
|
||
|
||
const handleToggle = (id: string, enabled: boolean) => {
|
||
const next = rules.map(r => r.id === id ? { ...r, enabled } : r)
|
||
if (isLive) void flushAlertsConfig(next, groups)
|
||
else {
|
||
setRules(next)
|
||
}
|
||
}
|
||
|
||
const handleDelete = (id: string) => {
|
||
const next = rules.filter(r => r.id !== id)
|
||
if (isLive) void flushAlertsConfig(next, groups)
|
||
else {
|
||
setRules(next)
|
||
}
|
||
}
|
||
|
||
const closeRuleSheet = useCallback(() => {
|
||
setSheetOpen(false)
|
||
setSheetEditId(null)
|
||
setSheetSnap(null)
|
||
}, [])
|
||
|
||
const openRuleSheetAdd = useCallback(() => {
|
||
setSheetEditId(null)
|
||
setSheetSnap(null)
|
||
setSheetKey((k) => k + 1)
|
||
setSheetOpen(true)
|
||
}, [])
|
||
|
||
const openRuleSheetEdit = useCallback((id: string) => {
|
||
const r = rules.find((x) => x.id === id)
|
||
if (!r) return
|
||
setSheetSnap(ruleToFormSnapshot(r))
|
||
setSheetEditId(id)
|
||
setSheetKey((k) => k + 1)
|
||
setSheetOpen(true)
|
||
}, [rules])
|
||
|
||
const handleRuleSheetSave = useCallback(
|
||
(payload: Omit<AlertRule, "id" | "lastFired" | "enabled">, editId: string | null) => {
|
||
if (editId) {
|
||
const cur = rules.find((x) => x.id === editId)
|
||
if (!cur) return
|
||
const next = rules.map((x) =>
|
||
x.id === editId ? { ...x, ...payload, id: editId, enabled: cur.enabled, lastFired: cur.lastFired } : x,
|
||
)
|
||
if (isLive) void flushAlertsConfig(next, groups)
|
||
else {
|
||
setRules(next)
|
||
}
|
||
return
|
||
}
|
||
const row: AlertRule = {
|
||
...payload,
|
||
id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `r${Date.now()}`,
|
||
enabled: true,
|
||
lastFired: null,
|
||
}
|
||
const next = [...rules, row]
|
||
if (isLive) void flushAlertsConfig(next, groups)
|
||
else {
|
||
setRules(next)
|
||
}
|
||
},
|
||
[rules, groups, isLive, flushAlertsConfig],
|
||
)
|
||
|
||
// Summary chip click handler
|
||
const handleChip = (sev: SeverityFilter) =>
|
||
setSevFilter(f => f === sev ? "all" : sev)
|
||
|
||
const chipActive = (sev: SeverityFilter) => sevFilter === sev
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
|
||
actions={
|
||
<>
|
||
<Button variant="outline" size="sm" disabled={loadBusy || (isLive && backendStatus === false)}
|
||
onClick={() => { void load() }}>
|
||
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
|
||
Обновить
|
||
</Button>
|
||
<Button size="sm" onClick={openRuleSheetAdd} disabled={isLive && backendStatus === false}>
|
||
<PlusIcon className="size-4" />Добавить правило
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
|
||
{isLive && backendStatus === false && (
|
||
<p className="text-xs text-amber-700 dark:text-amber-400 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
||
Бэкенд недоступен по текущему URL. Проверьте адрес в настройках источника данных или переключитесь в режим демо.
|
||
</p>
|
||
)}
|
||
|
||
{!isLive && (
|
||
<p className="text-xs text-muted-foreground rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||
Режим демо: показаны примерные данные без сохранения. Включите «Живые данные» в источнике данных для работы с БД.
|
||
</p>
|
||
)}
|
||
|
||
{loadError && (
|
||
<p className="text-xs text-destructive rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2">
|
||
{loadError}
|
||
</p>
|
||
)}
|
||
|
||
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
|
||
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
|
||
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", hintsExpanded && "rotate-180")} />
|
||
Подсказки: формат целей и «восстановился»
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent className="px-3 pb-3 text-[11px] text-muted-foreground leading-relaxed space-y-2 border-t border-border/60 pt-2">
|
||
<p>
|
||
Движок читает последние сэмплы из SQLite (uptime ресурсы, REST-пинг, пробы, GRE/BGP). Условие
|
||
срабатывает только если цепочка состояний совпадает с текстом правила.
|
||
</p>
|
||
<ul className="list-disc pl-4 space-y-1.5">
|
||
<li>
|
||
<span className="font-medium text-foreground">RTT / потери</span> — в объектах укажите ключ пробы
|
||
как в мониторинге: <code className="rounded bg-muted px-1 font-mono">имя пробы → target</code>.
|
||
</li>
|
||
<li>
|
||
<span className="font-medium text-foreground">Восстановился</span> — нужна история: после падения
|
||
в последних сэмплах виден возврат в online (REST и метрики ресурса объединяются). Один текущий
|
||
online без прошлого offline часто не даёт hit.
|
||
</li>
|
||
<li>
|
||
<span className="font-medium text-foreground">Перешёл в offline</span> — только на ребре (предыдущий
|
||
сэмпл был online). Пока сервер остаётся offline, повторные уведомления не шлются.
|
||
</li>
|
||
<li>
|
||
<span className="font-medium text-foreground">Стабильность / cooldown</span> — задержка перед
|
||
Telegram и пауза между повторными отправками. В разделе «Сбор данных» в деталях прогона «Оповещения»
|
||
есть таблица по каждому правилу (hit, стабильность, cooldown, Telegram, причина блока).
|
||
</li>
|
||
</ul>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* ── summary chip bar ── */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
|
||
{/* stat chips */}
|
||
{([
|
||
{ key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" },
|
||
{ key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip },
|
||
{ key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip },
|
||
{ key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip },
|
||
] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => (
|
||
<button key={key} onClick={() => handleChip(key)}
|
||
className={cn(
|
||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||
chipActive(key)
|
||
? cls
|
||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||
)}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
|
||
<div className="w-px h-4 bg-border mx-1" />
|
||
|
||
{/* active count chip */}
|
||
<button onClick={() => setOnlyActive(v => !v)}
|
||
className={cn(
|
||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||
onlyActive
|
||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||
)}>
|
||
Активных: {active}
|
||
</button>
|
||
|
||
{/* telegram status */}
|
||
<span className={cn(
|
||
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
|
||
tg.connected
|
||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
||
)}>
|
||
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
|
||
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* ── presets + groups (свёрнуто по умолчанию) ── */}
|
||
<Collapsible open={extrasExpanded} onOpenChange={setExtrasExpanded} className="rounded-lg border border-border bg-card shadow-sm">
|
||
<CollapsibleTrigger
|
||
className={cn(
|
||
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm outline-none transition-colors",
|
||
"hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
)}
|
||
>
|
||
<ChevronDownIcon
|
||
className={cn(
|
||
"size-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
||
extrasExpanded && "rotate-180",
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<span className="font-medium">Пресеты и группы ANY / ALL</span>
|
||
<span className="hidden sm:block text-[10px] text-muted-foreground font-normal leading-snug mt-0.5">
|
||
Массовое добавление правил и объединение нескольких правил в одно уведомление
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] text-muted-foreground shrink-0 tabular-nums">
|
||
{groups.length ? `${groups.length} гр.` : "групп нет"}
|
||
</span>
|
||
</CollapsibleTrigger>
|
||
<CollapsibleContent>
|
||
<div className="border-t border-border/60 px-4 pb-4 pt-3">
|
||
<div className="grid gap-5 lg:grid-cols-2">
|
||
<Card>
|
||
<CardHeader className="pb-2 pt-4 px-4">
|
||
<CardTitle className="text-sm">Пресеты</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 flex flex-col gap-3">
|
||
<p className="text-[10px] text-muted-foreground leading-snug">
|
||
Быстро добавить набор правил из каталога серверов (демо или live meta).
|
||
</p>
|
||
{ALERT_PRESETS.map((p) => (
|
||
<div
|
||
key={p.id}
|
||
className="rounded-lg border border-border/80 bg-muted/5 p-3 flex flex-col gap-2"
|
||
>
|
||
<p className="text-xs font-semibold">{p.title}</p>
|
||
<p className="text-[10px] text-muted-foreground leading-snug">{p.description}</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-7 text-xs"
|
||
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
|
||
onClick={() => applyPresetAppend(p.id)}
|
||
>
|
||
Добавить к списку
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-7 text-xs"
|
||
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
|
||
onClick={() => applyPresetReplace(p.id)}
|
||
>
|
||
Заменить все
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2 pt-4 px-4">
|
||
<CardTitle className="text-sm">Группы ANY / ALL</CardTitle>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-7 text-xs shrink-0"
|
||
disabled={rulesSaveBusy || (isLive && backendStatus === false)}
|
||
onClick={addGroup}
|
||
>
|
||
<PlusIcon className="size-3.5" />Группа
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 flex flex-col gap-3">
|
||
{groups.length === 0 ? (
|
||
<p className="text-[11px] text-muted-foreground">
|
||
Групп пока нет. Создайте группу и назначьте правилам её в форме правила — движок отправит одно сообщение по логике ANY или ALL.
|
||
</p>
|
||
) : (
|
||
groups.map((g) => (
|
||
<div
|
||
key={g.id}
|
||
className="rounded-lg border border-border/80 p-3 flex flex-col gap-2"
|
||
>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Input
|
||
value={g.name}
|
||
onChange={(e) => patchGroup(g.id, { name: e.target.value })}
|
||
className="h-8 text-xs flex-1 min-w-[140px]"
|
||
aria-label="Имя группы"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={g.enabled ? "secondary" : "outline"}
|
||
className="h-7 text-xs"
|
||
onClick={() => patchGroup(g.id, { enabled: !g.enabled })}
|
||
>
|
||
{g.enabled ? "Вкл." : "Выкл."}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||
onClick={() => removeGroup(g.id)}
|
||
>
|
||
Удалить
|
||
</Button>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2 items-center">
|
||
<span className="text-[10px] text-muted-foreground shrink-0">Режим</span>
|
||
<select
|
||
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
|
||
value={g.combineMode}
|
||
onChange={(e) =>
|
||
patchGroup(g.id, { combineMode: e.target.value === "all" ? "all" : "any" })
|
||
}
|
||
>
|
||
<option value="any">ANY (или)</option>
|
||
<option value="all">ALL (и)</option>
|
||
</select>
|
||
<span className="text-[10px] text-muted-foreground shrink-0">Cooldown группы</span>
|
||
<select
|
||
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[100px]"
|
||
value={g.cooldownOverride ?? ""}
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
patchGroup(g.id, {
|
||
cooldownOverride: v === "" ? null : (v as AlertCooldown),
|
||
})
|
||
}}
|
||
>
|
||
<option value="">По умолчанию</option>
|
||
{COOLDOWNS.map((c) => (
|
||
<option key={c} value={c}>
|
||
{c}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</CollapsibleContent>
|
||
</Collapsible>
|
||
|
||
{/* ── main layout ── */}
|
||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
|
||
|
||
{/* ── rules card ── */}
|
||
<Card>
|
||
<CardHeader className="pb-0 pt-4 px-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<CardTitle className="text-sm flex items-center gap-2">
|
||
Правила оповещения
|
||
{rulesSaveBusy && <LoaderCircleIcon className="size-3.5 animate-spin text-muted-foreground" aria-hidden />}
|
||
</CardTitle>
|
||
<Input
|
||
placeholder="Поиск…"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
autoComplete="off"
|
||
name="alert-rules-search"
|
||
className="h-7 text-xs max-w-[200px]"
|
||
/>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="px-0 pb-0 pt-2">
|
||
{filteredRules.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
|
||
<BellOffIcon className="size-8 mb-2 opacity-40" />
|
||
<p className="text-sm">Правила не найдены</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border/60">
|
||
{filteredRules.map(r => (
|
||
<AlertRuleRow
|
||
key={r.id}
|
||
rule={r}
|
||
onToggle={handleToggle}
|
||
onDelete={handleDelete}
|
||
onEdit={openRuleSheetEdit}
|
||
interactionsDisabled={rulesSaveBusy}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* footer */}
|
||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
||
<span>
|
||
{filteredRules.length} из {total} правил
|
||
{onlyActive && " · только активные"}
|
||
{sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={openRuleSheetAdd}
|
||
className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||
<PlusIcon className="size-3" />Добавить правило
|
||
</button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* ── right sidebar ── */}
|
||
<div className="flex flex-col gap-5">
|
||
<TelegramCard
|
||
cfg={tg}
|
||
onChange={setTg}
|
||
tokenConfigured={isLive ? tokenConfigured : false}
|
||
liveSaveBusy={telegramSaveBusy}
|
||
onSaveTelegram={isLive ? saveTelegram : undefined}
|
||
onTestTelegram={isLive ? testTelegram : undefined}
|
||
/>
|
||
<HistoryCard entries={history} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<AddRuleSheet
|
||
key={sheetKey}
|
||
open={sheetOpen}
|
||
onClose={closeRuleSheet}
|
||
onSave={handleRuleSheetSave}
|
||
typeTargets={typeTargets}
|
||
isLive={isLive}
|
||
serversCatalog={serversCatalog}
|
||
greTunnelCatalog={greTunnelCatalog}
|
||
groups={groups}
|
||
initialForm={sheetSnap}
|
||
editRuleId={sheetEditId}
|
||
onTestRuleTelegram={isLive ? testRuleTelegramFromForm : undefined}
|
||
testTelegramDisabled={isLive && backendStatus === false}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|