Init commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user