Init commit

This commit is contained in:
Denozordec
2026-05-02 01:17:08 +07:00
commit f3f831653f
104 changed files with 43827 additions and 0 deletions
+823
View File
@@ -0,0 +1,823 @@
"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(
// exact same tokens as the project's Input component
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent 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",
"dark:bg-input/30",
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>
)
}