"use client" import { useState, useMemo } from "react" import { PageHeader } from "@/components/page-header" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 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, CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon, TimerIcon, WifiOffIcon, TrendingDownIcon, EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon, Trash2Icon, } from "lucide-react" import { cn } from "@/lib/utils" // ─── types ──────────────────────────────────────────────────────────────────── type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic" type AlertSeverity = "critical" | "warning" | "info" type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч" interface AlertRule { id: string name: string type: AlertType target: string // display label condition: string // display label severity: AlertSeverity enabled: boolean cooldown: AlertCooldown lastFired: string | null // relative time string chatId: string // override; empty = use global } interface HistoryEntry { id: string ruleName: string severity: AlertSeverity message: string time: string sent: boolean } interface TelegramConfig { token: string chatId: string connected: boolean } // ─── static config ──────────────────────────────────────────────────────────── const TYPE_META: Record label: string iconClass: string bg: string }> = { "gre-tunnel": { Icon: CableIcon, label: "GRE-туннель", iconClass: "text-violet-500", bg: "bg-violet-500/10" }, "bgp-peer": { Icon: NetworkIcon, label: "BGP-сосед", iconClass: "text-blue-500", bg: "bg-blue-500/10" }, "bgp-prefix": { Icon: RouteIcon, label: "BGP-префикс", iconClass: "text-sky-500", bg: "bg-sky-500/10" }, "gre-client": { Icon: UserIcon, label: "GRE-клиент", iconClass: "text-purple-500", bg: "bg-purple-500/10" }, "server": { Icon: ServerIcon, label: "Сервер", iconClass: "text-slate-500", bg: "bg-slate-500/10" }, "rtt": { Icon: TimerIcon, label: "Задержка (RTT)", iconClass: "text-amber-500", bg: "bg-amber-500/10" }, "loss": { Icon: WifiOffIcon, label: "Потери пакетов", iconClass: "text-orange-500", bg: "bg-orange-500/10" }, "traffic": { Icon: TrendingDownIcon, label: "Низкий трафик", iconClass: "text-rose-500", bg: "bg-rose-500/10" }, } const SEVERITY_META: Record = { critical: { label: "Критическое", dot: "bg-red-500", badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400", chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400", }, warning: { label: "Предупреждение", dot: "bg-amber-400", badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400", chip: "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400", }, info: { label: "Информационное", dot: "bg-blue-500", badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400", chip: "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400", }, } const TYPE_TARGETS: Record = { "gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"], "bgp-peer": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"], "bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"], "gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"], "server": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"], "rtt": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"], "loss": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"], "traffic": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"], } const TYPE_CONDITIONS: Record = { "gre-tunnel": ["перешёл в offline", "восстановился"], "bgp-peer": ["разорвал сессию", "восстановил сессию"], "bgp-prefix": ["был отозван", "был получен"], "gre-client": ["отключился", "подключился"], "server": ["перешёл в offline", "перешёл в degraded", "восстановился"], "rtt": ["> порога", "< порога"], "loss": ["> порога"], "traffic": ["RX < порога", "TX < порога"], } const THRESHOLD_UNIT: Partial> = { rtt: "мс", loss: "%", traffic: "Мбит/с", } const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"] // ─── mock data ──────────────────────────────────────────────────────────────── const INIT_RULES: AlertRule[] = [ { id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "5м", lastFired: "3ч назад", chatId: "" }, { id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01", condition: "разорвал сессию", severity: "critical", enabled: true, cooldown: "15м", lastFired: "1д назад", chatId: "" }, { id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", condition: "> 120 мс", severity: "warning", enabled: true, cooldown: "15м", lastFired: "45м назад", chatId: "" }, { id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", condition: "> 5%", severity: "warning", enabled: true, cooldown: "5м", lastFired: null, chatId: "" }, { id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "1ч", lastFired: "2д назад", chatId: "" }, { id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", condition: "был отозван", severity: "info", enabled: true, cooldown: "5м", lastFired: "6ч назад", chatId: "-1009876543210" }, { id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", condition: "отключился", severity: "warning", enabled: false, cooldown: "1ч", lastFired: null, chatId: "" }, { id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", condition: "RX < 10 Мбит/с", severity: "info", enabled: false, cooldown: "4ч", lastFired: null, chatId: "" }, ] 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 }, ] const INIT_TG: TelegramConfig = { token: "7412358964:AAFkL9xZqBb2pC8nYrVtHmwKjXeOdSuN1A", chatId: "-1001234567890", connected: true, } // ─── small helpers ──────────────────────────────────────────────────────────── function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function FieldLabel({ children }: { children: React.ReactNode }) { return

{children}

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

{children}

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

{rule.name}

{TYPE_META[rule.type].label} · {rule.target} {" · "}{rule.condition}

{/* cooldown */} {rule.cooldown} {/* chat override badge */} {rule.chatId && ( #{rule.chatId.slice(-6)} )} {/* last fired */} {rule.lastFired ?? "—"} {/* severity badge */}
{/* actions (visible on hover) */}
) } // ─── telegram config card ───────────────────────────────────────────────────── function TelegramCard({ cfg, onChange }: { cfg: TelegramConfig onChange: (c: TelegramConfig) => void }) { const [showToken, setShowToken] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null) const handleTest = () => { setTesting(true) setTestResult(null) setTimeout(() => { setTesting(false) setTestResult(cfg.connected ? "ok" : "fail") setTimeout(() => setTestResult(null), 3000) }, 1400) } const _maskedToken = cfg.token.replace(/:.+/, ":••••••••••••••••••••••••") return ( ✈️ Telegram {cfg.connected ? "Подключён" : "Не настроен"} {/* bot token */}
Bot Token
onChange({ ...cfg, token: e.target.value })} className="text-xs font-mono h-8" placeholder="1234567890:AAF..." />
{/* default chat id */}
Chat ID по умолчанию onChange({ ...cfg, chatId: e.target.value })} className="text-xs font-mono h-8" placeholder="-100xxxxxxxxxx" />

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

{/* test button */}
) } // ─── history card ───────────────────────────────────────────────────────────── function HistoryCard({ entries }: { entries: HistoryEntry[] }) { return ( Журнал срабатываний {entries.length === 0 ? (

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

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

{e.ruleName}

{e.message}

{e.time}

{e.sent ? "✓ отправлено" : "✗ ошибка"}
))}
)}
) } // ─── add rule sheet ─────────────────────────────────────────────────────────── const BLANK_FORM = { name: "", type: "gre-tunnel" as AlertType, target: "", condition: "", threshold: "", severity: "warning" as AlertSeverity, cooldown: "5м" as AlertCooldown, chatId: "", } function AddRuleSheet({ open, onClose, onSave }: { open: boolean onClose: () => void onSave: (rule: Omit) => void }) { const [form, setForm] = useState({ ...BLANK_FORM }) // reset when open const handleOpen = (o: boolean) => { if (o) setForm({ ...BLANK_FORM }) } const targets = TYPE_TARGETS[form.type] const conditions = TYPE_CONDITIONS[form.type] const unit = THRESHOLD_UNIT[form.type] const conditionDisplay = unit && form.threshold ? `${form.condition.replace("порога", form.threshold + " " + unit)}` : form.condition const canSave = form.name.trim() && form.target && form.condition && (!unit || form.threshold) const handleSave = () => { if (!canSave) return onSave({ name: form.name.trim(), type: form.type, target: form.target, condition: conditionDisplay || form.condition, severity: form.severity, cooldown: form.cooldown, chatId: form.chatId.trim(), }) onClose() } return ( { if (!o) onClose(); handleOpen(o) }}> {/* p-0 + gap-0: we control all spacing internally */} {/* ── fixed header ── */} Новое правило оповещения {/* ── scrollable body ── */}
{/* name */}
Название правила setForm(f => ({ ...f, name: e.target.value }))} />
{/* type */}
Тип события setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))} > {(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => ( ))}
{/* target + condition on same row when both are simple selects */}
Объект setForm(f => ({ ...f, target: v }))}> {targets.map(t => )}
Условие setForm(f => ({ ...f, condition: v }))}> {conditions.map(c => )}
{/* threshold — only for RTT / loss / traffic */} {unit && (
Пороговое значение
setForm(f => ({ ...f, threshold: e.target.value }))} className="rounded-r-none" placeholder="0" /> {unit}
)} {/* severity — vertical radio cards */}
Серьёзность
{(["critical", "warning", "info"] as AlertSeverity[]).map(s => { const active = form.severity === s return ( ) })}
{/* cooldown */}
Повторять не чаще чем
{COOLDOWNS.map(c => ( ))}
{/* chat id override */}
Chat ID — переопределить setForm(f => ({ ...f, chatId: e.target.value }))} className="font-mono text-sm" placeholder="-100xxxxxxxxxx" /> Пусто → используется глобальный Chat ID из настроек Telegram
{/* Telegram message preview */} {form.name && form.target && form.condition && (
Предпросмотр сообщения

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

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

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

Cooldown: {form.cooldown}

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

)}
{/* ── fixed footer ── */}
) } // ─── page ───────────────────────────────────────────────────────────────────── type SeverityFilter = AlertSeverity | "all" export default function AlertsPage() { const [rules, setRules] = useState(INIT_RULES) const [history] = useState(INIT_HISTORY) const [tg, setTg] = useState(INIT_TG) const [sheetOpen, setSheetOpen] = useState(false) const [sevFilter, setSevFilter] = useState("all") const [onlyActive, setOnlyActive] = useState(false) const [search, setSearch] = useState("") 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 if (search && !r.name.toLowerCase().includes(search.toLowerCase()) && !r.target.toLowerCase().includes(search.toLowerCase())) return false return true }), [rules, sevFilter, onlyActive, search]) const handleToggle = (id: string, enabled: boolean) => setRules(rs => rs.map(r => r.id === id ? { ...r, enabled } : r)) const handleDelete = (id: string) => setRules(rs => rs.filter(r => r.id !== id)) const handleAdd = (rule: Omit) => { setRules(rs => [...rs, { ...rule, id: `r${Date.now()}`, enabled: true, lastFired: null, }]) } // Summary chip click handler const handleChip = (sev: SeverityFilter) => setSevFilter(f => f === sev ? "all" : sev) const chipActive = (sev: SeverityFilter) => sevFilter === sev return (
} />
{/* ── summary chip bar ── */}
{/* stat chips */} {([ { key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" }, { key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip }, { key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip }, { key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip }, ] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => ( ))}
{/* active count chip */} {/* telegram status */} {tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
{/* ── main layout ── */}
{/* ── rules card ── */}
Правила оповещения setSearch(e.target.value)} className="h-7 text-xs max-w-[200px]" />
{filteredRules.length === 0 ? (

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

) : (
{filteredRules.map(r => ( ))}
)} {/* footer */}
{filteredRules.length} из {total} правил {onlyActive && " · только активные"} {sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
{/* ── right sidebar ── */}
setSheetOpen(false)} onSave={handleAdd} />
) }