Files
MikrotikManager/app/(main)/alerts/page.tsx
T
2026-05-03 11:16:07 +07:00

822 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { 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<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"],
}
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ч"]
// ─── 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 (
<button role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
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 }: { children: React.ReactNode }) {
return <p className="text-sm font-medium mb-1.5 leading-none">{children}</p>
}
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
}
function NativeSelect({ value, onChange, children, className }: {
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-background px-2.5 py-1",
"text-sm text-foreground transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
className,
)}
>
{children}
</select>
)
}
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 }: {
rule: AlertRule
onToggle: (id: string, enabled: boolean) => void
onDelete: (id: string) => void
}) {
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)} />
{/* 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.target}</span>
{" · "}{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>
{/* 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">
<button
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">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
)
}
// ─── 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 (
<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 })}
className="text-xs font-mono h-8"
placeholder="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>
{/* test button */}
<Button
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
onClick={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>
)
}
// ─── 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<AlertRule, "id" | "lastFired" | "enabled">) => 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 (
<Sheet open={open} onOpenChange={o => { if (!o) onClose(); handleOpen(o) }}>
{/* p-0 + gap-0: we control all spacing internally */}
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
{/* ── 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" />
Новое правило оповещения
</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">
<FieldLabel>Название правила</FieldLabel>
<Input
placeholder="Например: GRE-туннель Иванов offline"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
{/* type */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Тип события</FieldLabel>
<NativeSelect
value={form.type}
onChange={v => setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))}
>
{(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => (
<option key={t} value={t}>{m.label}</option>
))}
</NativeSelect>
</div>
{/* target + condition on same row when both are simple selects */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<FieldLabel>Объект</FieldLabel>
<NativeSelect value={form.target} onChange={v => setForm(f => ({ ...f, target: v }))}>
<option value="" disabled> выбрать </option>
{targets.map(t => <option key={t} value={t}>{t}</option>)}
</NativeSelect>
</div>
<div className="flex flex-col gap-1.5">
<FieldLabel>Условие</FieldLabel>
<NativeSelect value={form.condition} onChange={v => setForm(f => ({ ...f, condition: v }))}>
<option value="" disabled> выбрать </option>
{conditions.map(c => <option key={c} value={c}>{c}</option>)}
</NativeSelect>
</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}
onChange={e => 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} onClick={() => 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} onClick={() => 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>
{/* chat id override */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Chat ID переопределить</FieldLabel>
<Input
value={form.chatId}
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.target && form.condition && (
<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.target}</p>
<p className="text-muted-foreground">
Событие: {conditionDisplay || form.condition}
</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-row gap-2">
<Button variant="outline" className="flex-1" onClick={onClose}>Отмена</Button>
<Button className="flex-1" onClick={handleSave} disabled={!canSave}>
Сохранить правило
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
type SeverityFilter = AlertSeverity | "all"
export default function AlertsPage() {
const [rules, setRules] = useState<AlertRule[]>(INIT_RULES)
const [history] = useState<HistoryEntry[]>(INIT_HISTORY)
const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
const [sheetOpen, setSheetOpen] = useState(false)
const [sevFilter, setSevFilter] = useState<SeverityFilter>("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<AlertRule, "id" | "lastFired" | "enabled">) => {
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 (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
actions={
<>
<Button variant="outline" size="sm">
<RefreshCwIcon className="size-4" />Обновить
</Button>
<Button size="sm" onClick={() => setSheetOpen(true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* ── 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>
{/* ── 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">Правила оповещения</CardTitle>
<Input
placeholder="Поиск…"
value={search}
onChange={e => setSearch(e.target.value)}
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}
/>
))}
</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
onClick={() => setSheetOpen(true)}
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} />
<HistoryCard entries={history} />
</div>
</div>
</div>
</div>
<AddRuleSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
onSave={handleAdd}
/>
</div>
)
}