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>
)
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { asns } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
export default function AsnsPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "ASN" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить ASN</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">ASN</h1>
<p className="text-sm text-muted-foreground mt-1">
Автономные системы источники маршрутов для BGP-фильтров
</p>
</div>
<DataTable
data={asns}
searchPlaceholder="Поиск по ASN, организации…"
searchKeys={["asn", "org", "country"]}
columns={[
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono font-semibold">{d.asn}</span>,
},
{
key: "org",
label: "Организация",
render: (d) => <span className="font-medium">{d.org}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
},
{
key: "prefixes",
label: "Префиксов",
render: (d) => <span className="font-mono tabular-nums">{d.prefixes.toLocaleString("ru")}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
</div>
</div>
</div>
)
}
+680
View File
@@ -0,0 +1,680 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { StatusBadge } from "@/components/status-badge"
import { backups as initialBackups, servers } from "@/lib/data"
import type { Backup } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
RefreshCwIcon, PlusIcon, DownloadIcon, Trash2Icon,
HardDriveIcon, ClockIcon, ServerIcon, CheckCircleIcon,
FolderIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── small UI helpers ─────────────────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 pb-1">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── types ────────────────────────────────────────────────────────────────────
type PageTab = "history" | "settings"
type KindFilter = "all" | "auto" | "manual"
type BackupFreq = "daily" | "weekly" | "monthly"
type StorageType = "local" | "ftp" | "scp" | "smb"
type BackupFormat = "rsc" | "backup"
const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
// ─── defaults ─────────────────────────────────────────────────────────────────
const defaultSchedule = {
enabled: true,
frequency: "daily" as BackupFreq,
hour: 3,
minute: 0,
weekDay: 0,
monthDay: 1,
keepCount: 7,
format: "rsc" as BackupFormat,
}
const defaultStorage = {
type: "local" as StorageType,
localPath: "/var/backup/mikrotik",
host: "",
port: "",
username: "",
password: "",
remotePath: "/mikrotik-backups",
share: "backups",
showPassword: false,
}
// ════════════════════════════════════════════════════════════════════════════
export default function BackupsPage() {
// ── state ──────────────────────────────────────────────────────────────
const [tab, setTab] = useState<PageTab>("history")
const [backupList, setBackupList] = useState<Backup[]>(initialBackups)
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
// Schedule
const [schedule, setSchedule] = useState(defaultSchedule)
const setSched = <K extends keyof typeof defaultSchedule>(k: K, v: (typeof defaultSchedule)[K]) =>
setSchedule((s) => ({ ...s, [k]: v }))
// Storage
const [storage, setStorage] = useState(defaultStorage)
const setStore = <K extends keyof typeof defaultStorage>(k: K, v: (typeof defaultStorage)[K]) =>
setStorage((s) => ({ ...s, [k]: v }))
// Server selection (all enabled by default)
const [selectedServers, setSelectedServers] = useState<Set<string>>(
new Set(servers.map((s) => s.id))
)
function toggleServer(id: string) {
setSelectedServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
// Settings saved flash
const [saved, setSaved] = useState(false)
function handleSave() {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
// Manual backup sheet
const [manualOpen, setManualOpen] = useState(false)
const [manualServers, setManualServers] = useState<Set<string>>(new Set())
const [manualNotes, setManualNotes] = useState("")
function toggleManualServer(id: string) {
setManualServers((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
function handleManualBackup() {
const now = new Date()
const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}`
const newBackups: Backup[] = Array.from(manualServers).map((sid, i) => {
const sv = servers.find((s) => s.id === sid)!
return {
id: `b${Date.now()}${i}`,
server: sv.name,
filename: `${sv.name}_${ts}_manual.rsc`,
size: `${Math.floor(60 + Math.random()*80)} КБ`,
created: "Только что",
kind: "manual",
notes: manualNotes || "",
}
})
setBackupList((list) => [...newBackups, ...list])
setManualOpen(false)
setManualServers(new Set())
setManualNotes("")
setTab("history")
}
// Delete backup
function handleDelete(id: string) {
setBackupList((list) => list.filter((b) => b.id !== id))
}
// ── derived ────────────────────────────────────────────────────────────
const filtered = useMemo(() =>
backupList.filter((b) => kindFilter === "all" || b.kind === kindFilter),
[backupList, kindFilter]
)
const autoCount = backupList.filter((b) => b.kind === "auto").length
const manualCount = backupList.filter((b) => b.kind === "manual").length
const serverCount = new Set(backupList.map((b) => b.server)).size
// Schedule summary string
const schedSummary = (() => {
if (!schedule.enabled) return "Отключено"
const t = `${String(schedule.hour).padStart(2,"0")}:${String(schedule.minute).padStart(2,"0")}`
if (schedule.frequency === "daily") return `Каждый день в ${t}`
if (schedule.frequency === "weekly") return `Каждую неделю (${WEEK_DAYS[schedule.weekDay]}) в ${t}`
return `${schedule.monthDay}-го числа каждого месяца в ${t}`
})()
// Storage path summary
const pathSummary = storage.type === "local"
? storage.localPath || "/var/backup/mikrotik"
: `${storage.type.toUpperCase()}://${storage.host || "host"}${storage.remotePath || "/"}`
// ── render ─────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Бэкапы" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => { setManualServers(new Set(servers.map(s=>s.id))); setManualOpen(true) }}>
<RefreshCwIcon className="size-4" />Снять со всех
</Button>
<Button size="sm" onClick={() => { setManualServers(new Set()); setManualNotes(""); setManualOpen(true) }}>
<PlusIcon className="size-4" />Новый бэкап
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего бэкапов", value: backupList.length, icon: <HardDriveIcon className="size-4" /> },
{ label: "Авто", value: autoCount, icon: <ClockIcon className="size-4" /> },
{ label: "Вручную", value: manualCount, icon: <PlusIcon className="size-4" /> },
{ label: "Серверов охвачено",value: serverCount, icon: <ServerIcon className="size-4" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<span className="text-muted-foreground/40">{s.icon}</span>
</CardContent>
</Card>
))}
</div>
{/* Info bar */}
<div className="flex items-center gap-4 text-xs text-muted-foreground px-1">
<span className="flex items-center gap-1.5">
<ClockIcon className="size-3" />
{schedSummary}
</span>
<span className="h-3 w-px bg-border" />
<span className="flex items-center gap-1.5">
<FolderIcon className="size-3" />
{pathSummary}
</span>
<span className="h-3 w-px bg-border" />
<span className="flex items-center gap-1.5">
<ServerIcon className="size-3" />
{selectedServers.size} из {servers.length} серверов
</span>
<button onClick={() => setTab("settings")}
className="ml-auto text-xs text-primary hover:underline">
Изменить настройки
</button>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-border">
{([
{ id: "history", label: "История бэкапов" },
{ id: "settings", label: "Настройки" },
] as { id: PageTab; label: string }[]).map((t) => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t.id ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
{t.label}
</button>
))}
</div>
{/* ── История ──────────────────────────────────────────────────── */}
{tab === "history" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "all", label: "Все", count: backupList.length },
{ value: "auto", label: "Авто", count: autoCount },
{ value: "manual", label: "Вручную", count: manualCount },
] as { value: KindFilter; label: string; count: number }[]).map((t) => (
<button key={t.value} onClick={() => setKindFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${kindFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} бэкапов</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Файл</th>
<th className="text-left font-medium px-4 py-3">Сервер</th>
<th className="text-left font-medium px-4 py-3">Размер</th>
<th className="text-left font-medium px-4 py-3">Тип</th>
<th className="text-left font-medium px-4 py-3">Заметки</th>
<th className="text-left font-medium px-4 py-3">Создан</th>
<th className="w-28 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-5 py-10 text-center text-sm text-muted-foreground">Нет бэкапов</td></tr>
)}
{filtered.map((b) => (
<tr key={b.id} className="hover:bg-muted/40 transition-colors group">
<td className="px-5 py-3 font-mono text-xs font-medium">{b.filename}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{b.server}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{b.size}</td>
<td className="px-4 py-3">
<span className={cn("text-xs px-2 py-0.5 rounded border font-medium",
b.kind === "manual"
? "bg-blue-500/10 text-blue-400 border-blue-500/20"
: "bg-muted text-muted-foreground border-border"
)}>
{b.kind === "auto" ? "авто" : "вручную"}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[200px] truncate">{b.notes || "—"}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{b.created}</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="size-7" title="Скачать">
<DownloadIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7" title="Восстановить">
<RefreshCwIcon className="size-3.5" />
</Button>
<Button variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive"
title="Удалить" onClick={() => handleDelete(b.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* ── Настройки ────────────────────────────────────────────────── */}
{tab === "settings" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Расписание */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<ClockIcon className="size-3.5" />}>Расписание</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Автоматический бэкап</p>
<p className="text-xs text-muted-foreground mt-0.5">Создавать бэкапы по расписанию</p>
</div>
<Toggle checked={schedule.enabled} onChange={(v) => setSched("enabled", v)} />
</div>
<div className={cn("flex flex-col gap-4 transition-opacity", !schedule.enabled && "opacity-40 pointer-events-none")}>
<Field label="Частота">
<SegmentedControl
value={schedule.frequency}
onChange={(v) => setSched("frequency", v)}
options={[
{ value: "daily", label: "Ежедневно" },
{ value: "weekly", label: "Еженедельно" },
{ value: "monthly", label: "Ежемесячно" },
]}
/>
</Field>
{schedule.frequency === "weekly" && (
<Field label="День недели">
<div className="flex gap-1">
{WEEK_DAYS.map((d, i) => (
<button key={i} type="button" onClick={() => setSched("weekDay", i)}
className={cn(
"w-9 h-9 rounded text-sm font-medium border transition-colors",
schedule.weekDay === i
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}>
{d}
</button>
))}
</div>
</Field>
)}
{schedule.frequency === "monthly" && (
<Field label="День месяца" hint="128">
<Input type="number" min={1} max={28} className="font-mono w-24"
value={schedule.monthDay}
onChange={(e) => setSched("monthDay", Math.min(28, Math.max(1, Number(e.target.value))))} />
</Field>
)}
<Field label="Время запуска">
<div className="flex items-center gap-2">
<div className="relative">
<Input type="number" min={0} max={23} className="font-mono w-20 text-center"
value={String(schedule.hour).padStart(2, "0")}
onChange={(e) => setSched("hour", Math.min(23, Math.max(0, Number(e.target.value))))} />
</div>
<span className="text-muted-foreground font-mono text-lg">:</span>
<div className="flex gap-1">
{[0, 15, 30, 45].map((m) => (
<button key={m} type="button" onClick={() => setSched("minute", m)}
className={cn(
"px-2.5 py-1.5 rounded text-xs font-mono border transition-colors",
schedule.minute === m
? "bg-primary text-primary-foreground border-primary"
: "border-border text-muted-foreground hover:text-foreground"
)}>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Хранить бэкапов" hint="На каждый сервер">
<Input type="number" min={1} max={90} className="font-mono"
value={schedule.keepCount}
onChange={(e) => setSched("keepCount", Math.max(1, Number(e.target.value)))} />
</Field>
<Field label="Формат файла">
<SegmentedControl
value={schedule.format}
onChange={(v) => setSched("format", v)}
options={[
{ value: "rsc", label: ".rsc" },
{ value: "backup", label: ".backup" },
]}
/>
</Field>
</div>
</div>
</CardContent>
</Card>
{/* Хранилище */}
<Card>
<CardContent className="px-5 py-5 flex flex-col gap-5">
<SectionTitle icon={<FolderIcon className="size-3.5" />}>Хранилище</SectionTitle>
<Field label="Тип хранилища">
<SegmentedControl
value={storage.type}
onChange={(v) => setStore("type", v)}
options={[
{ value: "local", label: "Локально" },
{ value: "ftp", label: "FTP" },
{ value: "scp", label: "SCP" },
{ value: "smb", label: "SMB" },
]}
/>
</Field>
{storage.type === "local" && (
<Field label="Путь сохранения" hint="Директория на сервере приложения">
<Input className="font-mono" placeholder="/var/backup/mikrotik"
value={storage.localPath}
onChange={(e) => setStore("localPath", e.target.value)} />
</Field>
)}
{storage.type !== "local" && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="Хост">
<Input className="font-mono" placeholder="192.168.1.100"
value={storage.host} onChange={(e) => setStore("host", e.target.value)} />
</Field>
</div>
<Field label="Порт">
<Input className="font-mono"
placeholder={storage.type === "ftp" ? "21" : storage.type === "scp" ? "22" : "445"}
value={storage.port} onChange={(e) => setStore("port", e.target.value)} />
</Field>
</div>
{storage.type === "smb" && (
<Field label="Общая папка (Share)">
<Input className="font-mono" placeholder="backups"
value={storage.share} onChange={(e) => setStore("share", e.target.value)} />
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Пользователь">
<Input className="font-mono" placeholder="backup-user"
value={storage.username} onChange={(e) => setStore("username", e.target.value)} />
</Field>
<Field label={storage.type === "scp" ? "Пароль / ключ" : "Пароль"}>
<div className="relative">
<Input
type={storage.showPassword ? "text" : "password"}
className="font-mono pr-8"
placeholder="••••••••"
value={storage.password}
onChange={(e) => setStore("password", e.target.value)}
/>
<button type="button"
onClick={() => setStore("showPassword", !storage.showPassword)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-xs">
{storage.showPassword ? "скрыть" : "показ"}
</button>
</div>
</Field>
</div>
<Field label="Удалённый путь">
<Input className="font-mono" placeholder="/mikrotik-backups"
value={storage.remotePath} onChange={(e) => setStore("remotePath", e.target.value)} />
</Field>
</>
)}
{/* Preview */}
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Путь сохранения</p>
<p className="font-mono text-foreground break-all">{pathSummary}</p>
{storage.type !== "local" && (
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{pathSummary}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
)}
{storage.type === "local" && (
<p className="mt-1">Файлы: <span className="font-mono text-foreground">{storage.localPath || "/var/backup/mikrotik"}/{"<server-name>"}_{"{date}"}.{schedule.format}</span></p>
)}
</div>
</CardContent>
</Card>
{/* Серверы */}
<Card className="lg:col-span-2">
<CardContent className="px-5 py-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<SectionTitle icon={<ServerIcon className="size-3.5" />}>Серверы для бэкапа</SectionTitle>
<div className="flex items-center gap-2 shrink-0">
<button type="button" onClick={() => setSelectedServers(new Set(servers.map(s => s.id)))}
className="text-xs text-primary hover:underline">Выбрать все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setSelectedServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
{servers.map((s) => {
const checked = selectedServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked
? "border-primary/40 bg-primary/5"
: "border-border hover:border-border/80 hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground">{s.site}</span>
<StatusBadge status={s.status} />
</div>
</div>
</button>
)
})}
</div>
<p className="text-xs text-muted-foreground">
Выбрано {selectedServers.size} из {servers.length} серверов
</p>
</CardContent>
</Card>
{/* Save button */}
<div className="lg:col-span-2 flex items-center gap-3">
<Button onClick={handleSave} className="gap-2">
Сохранить настройки
</Button>
{saved && (
<span className="flex items-center gap-1.5 text-sm text-emerald-500">
<CheckCircleIcon className="size-4" />
Настройки сохранены
</span>
)}
</div>
</div>
)}
</div>
</div>
{/* ══ Sheet: Manual backup ══════════════════════════════════════════════ */}
<Sheet open={manualOpen} onOpenChange={setManualOpen}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый бэкап</SheetTitle>
<SheetDescription>Снять конфигурацию вручную с выбранных серверов</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium">Выберите серверы</p>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setManualServers(new Set(servers.map(s=>s.id)))}
className="text-xs text-primary hover:underline">Все</button>
<span className="text-border">·</span>
<button type="button" onClick={() => setManualServers(new Set())}
className="text-xs text-muted-foreground hover:text-foreground hover:underline">Сбросить</button>
</div>
</div>
{servers.map((s) => {
const checked = manualServers.has(s.id)
return (
<button key={s.id} type="button" onClick={() => toggleManualServer(s.id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors",
checked ? "border-primary/40 bg-primary/5" : "border-border hover:bg-muted/40"
)}>
<div className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border transition-colors",
checked ? "bg-primary border-primary" : "border-border"
)}>
{checked && <svg width="10" height="8" viewBox="0 0 10 8" fill="none"><path d="M1 4l2.5 2.5L9 1" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{s.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs font-mono text-muted-foreground">{s.host}</span>
<StatusBadge status={s.status} />
</div>
</div>
{s.status === "offline" && (
<span className="text-xs text-muted-foreground">недоступен</span>
)}
</button>
)
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">Заметка</label>
<Input placeholder="Например: перед обновлением BGP"
value={manualNotes} onChange={(e) => setManualNotes(e.target.value)} />
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1"
disabled={manualServers.size === 0}
onClick={handleManualBackup}>
Снять бэкап ({manualServers.size})
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
+979
View File
@@ -0,0 +1,979 @@
"use client"
import { Fragment, useState, useMemo, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
RefreshCwIcon, DownloadIcon, SearchIcon,
ActivityIcon, BarChart3Icon, ChevronRightIcon, ChevronDownIcon,
ArrowDownIcon, ClipboardCopyIcon, ServerIcon,
XIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
// ─── types ────────────────────────────────────────────────────────────────────
type BgpState = "Established" | "Active" | "Idle" | "Connect" | "OpenSent" | "OpenConfirm"
type BgpType = "eBGP" | "iBGP"
type BgpAfi = "IPv4 Unicast" | "IPv6 Unicast" | "VPNv4 Unicast"
type BgpTab = "sessions" | "routers" | "analytics"
type StateFilter = "all" | BgpState
type TypeFilter = "all" | BgpType
interface BgpSession {
id: string
serverId: string
serverLabel: string
serverSite: string
peerIp: string
remoteAs: number
localAs: number
routerId: string
description: string
state: BgpState
type: BgpType
afi: BgpAfi
uptime: string | null
holdTime: number
keepalive: number
prefixesRx: number
prefixesTx: number
prefixesActive: number
inputMessages: number
outputMessages: number
capabilities: string[]
lastError: string | null
}
// ─── AS name lookup ───────────────────────────────────────────────────────────
const AS_NAMES: Record<number, string> = {
8359: "МТС / Tele2",
13238: "Яндекс",
12389: "Ростелеком",
24940: "Hetzner",
6777: "AMS-IX",
1299: "Telia",
65001: "iBGP internal",
}
// ─── mock data ────────────────────────────────────────────────────────────────
const SESSIONS: BgpSession[] = [
// ── srv1 (mt-msk-core-01) ────────────────────────────────────────────
{
id: "s1", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "185.1.1.1", remoteAs: 8359, localAs: 65001,
routerId: "185.1.1.1", description: "МТС — upstream transit",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "14д 6ч 22м", holdTime: 90, keepalive: 30,
prefixesRx: 280241, prefixesTx: 42, prefixesActive: 277894,
inputMessages: 1842204, outputMessages: 14412,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "ADD-PATH"],
lastError: null,
},
{
id: "s2", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "77.88.8.1", remoteAs: 13238, localAs: 65001,
routerId: "77.88.44.1", description: "Яндекс — IX peering",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "12д 3ч 11м", holdTime: 90, keepalive: 30,
prefixesRx: 11840, prefixesTx: 42, prefixesActive: 11840,
inputMessages: 184220, outputMessages: 14100,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s3", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.2", description: "iBGP → SPB-EDGE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "8д 14ч 5м", holdTime: 90, keepalive: 30,
prefixesRx: 620, prefixesTx: 280283, prefixesActive: 620,
inputMessages: 48210, outputMessages: 512800,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
lastError: null,
},
{
id: "s4", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.3", description: "iBGP → FRA-EDGE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "12д 2ч 18м", holdTime: 90, keepalive: 30,
prefixesRx: 512, prefixesTx: 280283, prefixesActive: 512,
inputMessages: 41022, outputMessages: 488200,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s5", serverId: "srv1", serverLabel: "mt-msk-core-01", serverSite: "MSK",
peerIp: "10.200.2.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.4", description: "iBGP → AMS-EDGE",
state: "Active", type: "iBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 0, outputMessages: 0,
capabilities: [],
lastError: "Hold timer expired",
},
// ── srv2 (mt-spb-edge-01) ────────────────────────────────────────────
{
id: "s6", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
peerIp: "195.54.55.1", remoteAs: 12389, localAs: 65001,
routerId: "195.54.55.1", description: "Ростелеком — upstream",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "9д 1ч 44м", holdTime: 90, keepalive: 30,
prefixesRx: 192440, prefixesTx: 42, prefixesActive: 191002,
inputMessages: 982440, outputMessages: 12200,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
{
id: "s7", serverId: "srv2", serverLabel: "mt-spb-edge-01", serverSite: "SPB",
peerIp: "10.200.0.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "8д 14ч 3м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 620, prefixesActive: 277000,
inputMessages: 512800, outputMessages: 48210,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Route-Target Constraint"],
lastError: null,
},
// ── srv3 (mt-fra-edge-01) ────────────────────────────────────────────
{
id: "s8", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "91.108.4.1", remoteAs: 24940, localAs: 65001,
routerId: "91.108.4.1", description: "Hetzner — upstream",
state: "Established", type: "eBGP", afi: "IPv4 Unicast",
uptime: "21д 7ч 12м", holdTime: 90, keepalive: 30,
prefixesRx: 218820, prefixesTx: 42, prefixesActive: 215400,
inputMessages: 1184200, outputMessages: 11800,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP", "Graceful Restart"],
lastError: null,
},
{
id: "s9", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "193.188.128.1", remoteAs: 6777, localAs: 65001,
routerId: "193.188.128.1", description: "AMS-IX — peering (idle)",
state: "Idle", type: "eBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 821, outputMessages: 412,
capabilities: [],
lastError: "Administratively down",
},
{
id: "s10", serverId: "srv3", serverLabel: "mt-fra-edge-01", serverSite: "FRA",
peerIp: "10.200.1.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "12д 2ч 14м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 512, prefixesActive: 278100,
inputMessages: 488200, outputMessages: 41022,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
// ── srv6 (mt-ams-test-01) ────────────────────────────────────────────
{
id: "s11", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
peerIp: "80.249.208.1", remoteAs: 6777, localAs: 65001,
routerId: "0.0.0.0", description: "AMS-IX — negotiating",
state: "OpenSent", type: "eBGP", afi: "IPv4 Unicast",
uptime: null, holdTime: 90, keepalive: 30,
prefixesRx: 0, prefixesTx: 0, prefixesActive: 0,
inputMessages: 2, outputMessages: 1,
capabilities: [],
lastError: null,
},
{
id: "s12", serverId: "srv6", serverLabel: "mt-ams-test-01", serverSite: "AMS",
peerIp: "10.200.5.1", remoteAs: 65001, localAs: 65001,
routerId: "10.0.0.1", description: "iBGP → MSK-CORE",
state: "Established", type: "iBGP", afi: "IPv4 Unicast",
uptime: "5д 9ч 17м", holdTime: 90, keepalive: 30,
prefixesRx: 280283, prefixesTx: 14, prefixesActive: 276000,
inputMessages: 184200, outputMessages: 4100,
capabilities: ["4-byte-AS", "Route Refresh", "MP-BGP"],
lastError: null,
},
]
// ─── visual helpers ───────────────────────────────────────────────────────────
const STATE_STYLE: Record<BgpState, { bg: string; text: string; dot: string; label: string }> = {
Established: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400", dot: "bg-emerald-500", label: "Established" },
Active: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", dot: "bg-amber-500", label: "Active" },
Idle: { bg: "bg-slate-500/10", text: "text-slate-500 dark:text-slate-400", dot: "bg-slate-500", label: "Idle" },
Connect: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", dot: "bg-blue-500", label: "Connect" },
OpenSent: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenSent" },
OpenConfirm: { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", dot: "bg-violet-500", label: "OpenConfirm" },
}
const TYPE_STYLE: Record<BgpType, { bg: string; text: string }> = {
eBGP: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" },
iBGP: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
}
function StateBadge({ state }: { state: BgpState }) {
const s = STATE_STYLE[state]
return (
<span className={cn("inline-flex items-center gap-1.5 rounded border px-2 py-0.5 text-[11px] font-semibold",
s.bg, s.text, "border-current/20")}>
<span className={cn("size-1.5 rounded-full shrink-0", s.dot)} />
{s.label}
</span>
)
}
function TypeBadge({ type }: { type: BgpType }) {
const s = TYPE_STYLE[type]
return (
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold",
s.bg, s.text, "border-current/20")}>
{type}
</span>
)
}
function CapChip({ cap }: { cap: string }) {
return (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border bg-muted/60 text-muted-foreground border-border/60">
{cap}
</span>
)
}
function fmtNum(n: number) {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`
return String(n)
}
function PrefixBar({ rx, tx, active }: { rx: number; tx: number; active: number }) {
const max = Math.max(rx, 1)
return (
<div className="flex flex-col gap-1.5 text-[10px] font-mono">
{[
{ label: "Получено", val: rx, color: "bg-emerald-500", w: rx / max },
{ label: "Активных", val: active, color: "bg-sky-500", w: active / max },
{ label: "Отправлено", val: tx, color: "bg-blue-400", w: Math.min(tx / max, 1) },
].map(r => (
<div key={r.label} className="flex items-center gap-2">
<span className="w-20 text-muted-foreground shrink-0">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max(r.w * 100, r.val > 0 ? 2 : 0)}%` }} />
</div>
<span className="w-14 text-right tabular-nums">{fmtNum(r.val)}</span>
</div>
))}
</div>
)
}
// ─── RSC snippet ──────────────────────────────────────────────────────────────
function rscSnippet(s: BgpSession) {
return `/routing bgp connection\nadd name=peer-as${s.remoteAs} remote.address=${s.peerIp}/32 \\\n remote.as=${s.remoteAs} local.role=${s.type === "eBGP" ? "ebgp" : "ibgp"} \\\n output.filter-chain=export-filter input.filter=import-filter \\\n routing-table=main`
}
// ─── session expanded row ─────────────────────────────────────────────────────
function SessionDetail({ s }: { s: BgpSession }) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard.writeText(rscSnippet(s)).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1800)
})
}
return (
<div className="px-4 pb-4 pt-2 bg-muted/20 border-t border-border/60">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{[
{ label: "Router ID", value: s.routerId },
{ label: "Hold / KA", value: `${s.holdTime}s / ${s.keepalive}s` },
{ label: "AFI/SAFI", value: s.afi },
{ label: "Сообщения ↓/↑", value: `${fmtNum(s.inputMessages)} / ${fmtNum(s.outputMessages)}` },
].map(({ label, value }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="text-xs font-mono font-medium">{value}</p>
</div>
))}
</div>
{/* prefix bars */}
{s.state === "Established" && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-2 uppercase tracking-wider font-semibold">Префиксы</p>
<PrefixBar rx={s.prefixesRx} tx={s.prefixesTx} active={s.prefixesActive} />
</div>
)}
{/* capabilities */}
{s.capabilities.length > 0 && (
<div className="mb-4">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">Capabilities</p>
<div className="flex flex-wrap gap-1.5">
{s.capabilities.map(c => <CapChip key={c} cap={c} />)}
</div>
</div>
)}
{/* last error */}
{s.lastError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2">
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
<p className="text-xs font-mono text-red-500">{s.lastError}</p>
</div>
)}
{/* rsc export */}
<div className="mt-2">
<p className="text-[10px] text-muted-foreground mb-1.5 uppercase tracking-wider font-semibold">RouterOS Export</p>
<div className="rounded-md bg-[#0a0f1a] border border-white/8 px-3 py-2.5 flex items-start justify-between gap-3">
<pre className="text-[10px] font-mono text-[#94a3b8] leading-relaxed whitespace-pre-wrap flex-1 min-w-0">
{rscSnippet(s)}
</pre>
<button onClick={copy}
className={cn(
"shrink-0 flex items-center gap-1 text-[10px] px-2 py-1 rounded border transition-colors",
copied
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 text-white/40 hover:text-white/70 hover:border-white/20",
)}>
<ClipboardCopyIcon className="size-3" />
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
</div>
</div>
)
}
// ─── backend mapping ──────────────────────────────────────────────────────────
interface BackendBgpSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
name: string; peerIp: string; remoteAs: number; localAs: number
localId: string; remoteId: string; state: string; type: "eBGP" | "iBGP"
uptime: string | null; holdTime: number; keepalive: number
prefixesRx: number; prefixesTx: number
inputMessages: number; outputMessages: number
capabilities: string[]; lastError: string | null
}
function backendToFrontend(b: BackendBgpSession): BgpSession {
return {
id: `${b.serverId}-${b.id}`,
serverId: String(b.serverId),
serverLabel: b.serverName,
serverSite: b.serverSite,
peerIp: b.peerIp,
remoteAs: b.remoteAs,
localAs: b.localAs,
routerId: b.remoteId || b.localId,
description: b.name,
state: (b.state as BgpState) || "Idle",
type: b.type,
afi: "IPv4 Unicast",
uptime: b.uptime,
holdTime: b.holdTime,
keepalive: b.keepalive,
prefixesRx: b.prefixesRx,
prefixesTx: b.prefixesTx,
prefixesActive: b.prefixesRx,
inputMessages: b.inputMessages,
outputMessages: b.outputMessages,
capabilities: b.capabilities,
lastError: b.lastError,
}
}
// ─── sessions tab ─────────────────────────────────────────────────────────────
const STATE_FILTERS: Array<{ value: StateFilter; label: string }> = [
{ value: "all", label: "Все" },
{ value: "Established", label: "Established" },
{ value: "Active", label: "Active" },
{ value: "Idle", label: "Idle" },
{ value: "OpenSent", label: "OpenSent" },
]
function SessionsTab({ sessions }: { sessions: BgpSession[] }) {
const [search, setSearch] = useState("")
const [stateFilter, setStateFilter] = useState<StateFilter>("all")
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const q = search.toLowerCase()
const filtered = useMemo(() => sessions.filter(s => {
if (stateFilter !== "all" && s.state !== stateFilter) return false
if (typeFilter !== "all" && s.type !== typeFilter) return false
if (q && !s.peerIp.includes(q) && !s.description.toLowerCase().includes(q)
&& !s.serverLabel.includes(q) && !String(s.remoteAs).includes(q)
&& !(AS_NAMES[s.remoteAs] ?? "").toLowerCase().includes(q)) return false
return true
}), [sessions, q, stateFilter, typeFilter])
return (
<div className="flex flex-col gap-4">
{/* filter bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* search */}
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="IP, AS, описание…"
className="h-8 pl-8 pr-3 w-52 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
/>
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3" />
</button>
)}
</div>
{/* state filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{STATE_FILTERS.map(f => (
<button key={f.value} onClick={() => setStateFilter(f.value)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors whitespace-nowrap",
stateFilter === f.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{f.label}
</button>
))}
</div>
{/* type filter */}
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-border bg-muted/40">
{(["all", "eBGP", "iBGP"] as const).map(t => (
<button key={t} onClick={() => setTypeFilter(t)}
className={cn(
"px-2.5 py-1 text-[11px] rounded transition-colors",
typeFilter === t ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}>
{t === "all" ? "Все типы" : t}
</button>
))}
</div>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} из {sessions.length}
</span>
</div>
{/* table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
<th className="w-8" />
{["Роутер", "Peer IP", "Remote AS", "Описание", "Тип", "Состояние", "Uptime", "Prefixes ↓", "Prefixes ↑"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{filtered.map(s => {
const isOpen = expandedId === s.id
return (
<Fragment key={s.id}>
<tr
onClick={() => setExpandedId(isOpen ? null : s.id)}
className={cn(
"cursor-pointer transition-colors",
isOpen ? "bg-muted/30" : "hover:bg-muted/20",
)}>
<td className="pl-3 py-2.5">
{isOpen
? <ChevronDownIcon className="size-3.5 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 text-muted-foreground" />}
</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{s.serverLabel}</td>
<td className="px-3 py-2.5 font-mono">{s.peerIp}</td>
<td className="px-3 py-2.5 font-mono">
<div className="flex items-center gap-1.5">
<span>AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-muted-foreground text-[10px]">{AS_NAMES[s.remoteAs]}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground max-w-[180px] truncate">{s.description}</td>
<td className="px-3 py-2.5"><TypeBadge type={s.type} /></td>
<td className="px-3 py-2.5"><StateBadge state={s.state} /></td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground">
{s.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesRx > 0
? <span className="text-emerald-600 dark:text-emerald-400">{fmtNum(s.prefixesRx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right">
{s.prefixesTx > 0
? <span className="text-blue-500">{fmtNum(s.prefixesTx)}</span>
: <span className="text-muted-foreground"></span>}
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={10} className="p-0">
<SessionDetail s={s} />
</td>
</tr>
)}
</Fragment>
)
})}
{filtered.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет сессий по заданным фильтрам
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
)
}
// ─── routers tab ──────────────────────────────────────────────────────────────
function RoutersTab({ sessions }: { sessions: BgpSession[] }) {
const byRouter = useMemo(() => {
const map: Record<string, { label: string; site: string; sessions: BgpSession[] }> = {}
sessions.forEach(s => {
if (!map[s.serverId]) map[s.serverId] = { label: s.serverLabel, site: s.serverSite, sessions: [] }
map[s.serverId].sessions.push(s)
})
return Object.entries(map).map(([id, v]) => ({ id, ...v }))
}, [sessions])
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{byRouter.map(router => {
const estCnt = router.sessions.filter(s => s.state === "Established").length
const downCnt = router.sessions.length - estCnt
const totalRx = router.sessions.reduce((a, s) => a + s.prefixesRx, 0)
return (
<Card key={router.id} className="overflow-hidden gap-0 py-0">
{/* header */}
<div className="flex items-center gap-3 px-4 py-3 border-b">
<ServerIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.label}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.site} · AS65001</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{estCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 border border-emerald-500/25 font-medium">
{estCnt}
</span>
)}
{downCnt > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/25 font-medium">
{downCnt}
</span>
)}
</div>
</div>
{/* session rows */}
<div className="divide-y divide-border/60">
{router.sessions.map(s => {
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono">{s.peerIp}</span>
<TypeBadge type={s.type} />
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-muted-foreground">AS{s.remoteAs}</span>
{AS_NAMES[s.remoteAs] && (
<span className="text-[10px] text-muted-foreground/60">· {AS_NAMES[s.remoteAs]}</span>
)}
</div>
</div>
<div className="shrink-0 text-right">
{s.prefixesRx > 0 && (
<p className="text-[10px] font-mono text-emerald-500 tabular-nums">
{fmtNum(s.prefixesRx)}
</p>
)}
<p className={cn("text-[10px] font-semibold", ss.text)}>{s.state}</p>
</div>
</div>
)
})}
</div>
{/* footer summary */}
{totalRx > 0 && (
<div className="flex items-center gap-2 px-4 py-2 border-t bg-muted/20">
<ArrowDownIcon className="size-3 text-emerald-500" />
<span className="text-[11px] font-mono text-muted-foreground">
Всего получено: <span className="text-emerald-600 dark:text-emerald-400 font-semibold">{fmtNum(totalRx)}</span> префиксов
</span>
</div>
)}
</Card>
)
})}
</div>
)
}
// ─── analytics tab ────────────────────────────────────────────────────────────
function AnalyticsTab({ sessions }: { sessions: BgpSession[] }) {
// top peers by prefixes received (eBGP only)
const topPeers = useMemo(() =>
[...sessions]
.filter(s => s.prefixesRx > 0)
.sort((a, b) => b.prefixesRx - a.prefixesRx)
.slice(0, 8),
[sessions]
)
const maxRx = topPeers[0]?.prefixesRx ?? 1
// state distribution
const stateCounts = useMemo(() => {
const map: Record<string, number> = {}
sessions.forEach(s => { map[s.state] = (map[s.state] ?? 0) + 1 })
return Object.entries(map).sort((a, b) => b[1] - a[1]) as [BgpState, number][]
}, [sessions])
// total prefix stats
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const totalActive = sessions.reduce((a, s) => a + s.prefixesActive, 0)
const ebgpSessions = sessions.filter(s => s.type === "eBGP").length
const ibgpSessions = sessions.filter(s => s.type === "iBGP").length
return (
<div className="flex flex-col gap-5">
{/* summary row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Всего префиксов", value: fmtNum(totalRx), color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Активных маршрутов", value: fmtNum(totalActive), color: "text-sky-600 dark:text-sky-400" },
{ label: "eBGP сессий", value: ebgpSessions, color: "" },
{ label: "iBGP сессий", value: ibgpSessions, color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-5">
{/* prefixes by peer — horizontal bar chart */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-4">Топ-8 пиров по полученным префиксам</p>
<div className="flex flex-col gap-3">
{topPeers.map((s, i) => {
const pct = (s.prefixesRx / maxRx) * 100
const ss = STATE_STYLE[s.state]
return (
<div key={s.id} className="flex items-center gap-3">
<span className="text-[11px] font-mono text-muted-foreground w-4 tabular-nums text-right">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1 gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-mono truncate">{s.peerIp}</span>
<span className="text-[10px] text-muted-foreground shrink-0">AS{s.remoteAs}</span>
<TypeBadge type={s.type} />
</div>
<span className={cn("text-[10px] font-semibold shrink-0 tabular-nums font-mono", "text-emerald-600 dark:text-emerald-400")}>
{fmtNum(s.prefixesRx)}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", ss.dot)}
style={{ width: `${pct}%`, opacity: s.state === "Established" ? 0.8 : 0.3 }}
/>
</div>
<p className="text-[10px] text-muted-foreground mt-0.5">{s.serverLabel}</p>
</div>
</div>
)
})}
{topPeers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">Нет данных о префиксах</p>
)}
</div>
</CardContent>
</Card>
{/* right column */}
<div className="flex flex-col gap-4">
{/* state distribution */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">Распределение состояний</p>
{stateCounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<div className="flex flex-col gap-2">
{stateCounts.map(([state, count]) => {
const ss = STATE_STYLE[state]
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
return (
<div key={state} className="flex items-center gap-2">
<span className={cn("size-2 rounded-full shrink-0", ss.dot)} />
<span className="text-xs w-24">{state}</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", ss.dot)} style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
<span className="text-xs font-mono tabular-nums w-6 text-right">{count}</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* eBGP vs iBGP */}
<Card>
<CardContent className="pt-4 pb-4 px-5">
<p className="text-sm font-semibold mb-3">eBGP vs iBGP</p>
{sessions.length === 0 ? (
<p className="text-xs text-muted-foreground">Нет данных</p>
) : (
<>
{[
{ type: "eBGP" as BgpType, label: "Внешние (eBGP)", count: ebgpSessions },
{ type: "iBGP" as BgpType, label: "Внутренние (iBGP)", count: ibgpSessions },
].map(({ type, count }) => {
const pct = sessions.length > 0 ? (count / sessions.length) * 100 : 0
return (
<div key={type} className="flex items-center gap-2 mb-2 last:mb-0">
<TypeBadge type={type} />
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", type === "eBGP" ? "bg-blue-500" : "bg-purple-500")}
style={{ width: `${pct}%`, opacity: 0.75 }} />
</div>
<span className="text-xs font-mono w-4 text-right">{count}</span>
</div>
)
})}
{/* active AS list */}
<div className="mt-4 pt-3 border-t border-border/60">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-2 font-semibold">Автономные системы</p>
<div className="flex flex-col gap-1">
{Object.entries(
sessions.filter(s => s.type === "eBGP").reduce<Record<number, number>>((acc, s) => {
acc[s.remoteAs] = (acc[s.remoteAs] ?? 0) + 1; return acc
}, {})
).sort((a, b) => b[1] - a[1]).map(([as, cnt]) => (
<div key={as} className="flex items-center justify-between text-[11px]">
<span className="font-mono text-muted-foreground">AS{as}</span>
<span className="text-muted-foreground/60">{AS_NAMES[Number(as)] ?? ""}</span>
<span className="font-mono tabular-nums">{cnt}</span>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
{ id: "sessions", label: "Сессии", icon: <ActivityIcon className="size-3.5" /> },
{ id: "routers", label: "По роутерам", icon: <ServerIcon className="size-3.5" /> },
{ id: "analytics", label: "Аналитика", icon: <BarChart3Icon className="size-3.5" /> },
]
export default function BgpPage() {
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
const [loading, setLoading] = useState(false)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
useEffect(() => {
if (!isLive) { setLiveSessions([]); return }
let cancelled = false
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
.then(data => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
setFetchedAt(new Date())
setLoading(false)
})
.catch((err: unknown) => {
if (cancelled) return
setLiveError(err instanceof Error ? err.message : String(err))
setLoading(false)
})
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
// Use live or mock data for all tabs and KPI
const sessions = isLive ? liveSessions : SESSIONS
const established = sessions.filter(s => s.state === "Established").length
const notEstab = sessions.length - established
const totalRx = sessions.reduce((a, s) => a + s.prefixesRx, 0)
const serverCount = useMemo(
() => new Set(liveSessions.map(s => s.serverId)).size,
[liveSessions],
)
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "BGP" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
}
/>
{/* tab bar */}
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => setActiveTab(t.id)}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCwIcon className="size-3.5 animate-spin" />
Загрузка BGP-сессий
</div>
)}
{isLive && !loading && fetchedAt && !liveError && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
Живые данные · {serverCount} серверов · обновлено {fetchedAt.toLocaleTimeString("ru")}
</span>
<button
onClick={() => setFetchTick(t => t + 1)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<RefreshCwIcon className="size-3" />
Обновить
</button>
</div>
)}
{isLive && liveError && (
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
</div>
)}
{isLive && !loading && liveSessions.length === 0 && !liveError && fetchedAt && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
BGP не настроен ни на одном сервере
</div>
)}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
{/* KPI strip */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ label: "Сессий всего", value: sessions.length, color: "" },
{ label: "Established", value: established, color: "text-emerald-600 dark:text-emerald-400" },
{ label: "Не установлено", value: notEstab, color: notEstab > 0 ? "text-amber-500" : "text-muted-foreground" },
{ label: "Получено префиксов", value: fmtNum(totalRx),color: "" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums mt-0.5", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
{/* alert: not-established sessions */}
{notEstab > 0 && (
<div className="flex items-center gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/8 px-4 py-2.5">
<span className="size-2 rounded-full bg-amber-500 shrink-0" />
<p className="text-sm text-amber-600 dark:text-amber-400">
<span className="font-semibold">{notEstab} сессии</span> не в состоянии Established
проверьте {sessions.filter(s => s.state !== "Established").map(s => s.peerIp).join(", ")}
</p>
</div>
)}
{/* tab content */}
{activeTab === "sessions" && <SessionsTab sessions={sessions} />}
{activeTab === "routers" && <RoutersTab sessions={sessions} />}
{activeTab === "analytics" && <AnalyticsTab sessions={sessions} />}
</div>
</div>
</div>
)
}
+397
View File
@@ -0,0 +1,397 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { routerCertificates, servers } from "@/lib/data"
import type { RouterCertificate, CertStatus } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
SearchIcon, ShieldCheckIcon, ShieldAlertIcon, ShieldOffIcon,
BadgeCheckIcon, AlertTriangleIcon, AlertCircleIcon,
CalendarIcon, KeyRoundIcon, ServerIcon, PlusIcon,
ChevronDownIcon, ChevronRightIcon,
} from "lucide-react"
// ─── helpers ──────────────────────────────────────────────────────────────────
const STATUS_CONFIG: Record<CertStatus, {
label: string; icon: React.ReactNode; badge: string; row: string
}> = {
valid: {
label: "Действителен",
icon: <BadgeCheckIcon className="size-4 text-emerald-500" />,
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
row: "",
},
expired: {
label: "Истёк",
icon: <ShieldOffIcon className="size-4 text-red-500" />,
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
row: "bg-red-500/5",
},
revoked: {
label: "Отозван",
icon: <ShieldAlertIcon className="size-4 text-amber-500" />,
badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
row: "bg-amber-500/5",
},
}
function daysLeftColor(days: number): string {
if (days < 0) return "text-red-500"
if (days <= 7) return "text-red-500"
if (days <= 30) return "text-amber-500"
return "text-emerald-600 dark:text-emerald-400"
}
function daysLeftBar(days: number, total = 365): number {
if (days <= 0) return 0
return Math.min(100, Math.round((days / total) * 100))
}
function serverForCert(cert: RouterCertificate) {
return servers.find((s) => s.id === cert.serverId)
}
// ─── Certificate row ──────────────────────────────────────────────────────────
function CertRow({
cert,
expanded,
onToggle,
}: {
cert: RouterCertificate
expanded: boolean
onToggle: () => void
}) {
const srv = serverForCert(cert)
const cfg = STATUS_CONFIG[cert.status]
const pct = daysLeftBar(cert.daysLeft)
return (
<div className={cn("border-b last:border-b-0", cfg.row)}>
<div
className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggle}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggle() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name */}
<div className="min-w-0">
<div className="flex items-center gap-2">
{cfg.icon}
<span className="font-medium text-sm truncate">{cert.name}</span>
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5 truncate">{cert.commonName}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv ? <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></> : <ServerIcon className="size-3.5" />}
</div>
{/* issued by */}
<p className="text-xs text-muted-foreground truncate">{cert.issuedBy}</p>
{/* days left */}
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className={cn("font-mono font-medium", daysLeftColor(cert.daysLeft))}>
{cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`}
</span>
<span className="text-muted-foreground text-[10px]">{cert.validUntil}</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all",
cert.daysLeft < 0 ? "bg-red-500" :
cert.daysLeft <= 7 ? "bg-red-500" :
cert.daysLeft <= 30 ? "bg-amber-500" :
"bg-emerald-500"
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* usage badges */}
<div className="flex flex-wrap gap-1">
{cert.usage.map((u) => (
<span key={u} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">
{u}
</span>
))}
</div>
{/* status */}
<span className={cn("text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap", cfg.badge)}>
{cfg.label}
</span>
</div>
{/* expanded detail */}
{expanded && (
<div className="px-10 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs border-t border-border/50 pt-3">
<div>
<p className="text-muted-foreground mb-1">Key size</p>
<p className="font-mono font-medium">{cert.keySize} bit</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Действителен с</p>
<p className="font-mono">{cert.validFrom}</p>
</div>
<div>
<p className="text-muted-foreground mb-1">SAN / Alt Names</p>
<div className="flex flex-wrap gap-1">
{cert.sans.length > 0
? cert.sans.map((s) => <span key={s} className="font-mono bg-muted px-1.5 py-0.5 rounded">{s}</span>)
: <span className="text-muted-foreground"></span>}
</div>
</div>
<div>
<p className="text-muted-foreground mb-1">Trusted</p>
<p className={cert.trusted ? "text-emerald-600 dark:text-emerald-400" : "text-red-500"}>
{cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"}
</p>
</div>
</div>
)}
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function CertificatesPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<CertStatus | "all">("all")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const expiring = useMemo(
() => routerCertificates.filter((c) => c.status === "valid" && c.daysLeft >= 0 && c.daysLeft <= 30),
[],
)
const expired = useMemo(() => routerCertificates.filter((c) => c.status === "expired"), [])
const filtered = useMemo(() => {
return routerCertificates.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.commonName.toLowerCase().includes(q) ||
c.issuedBy.toLowerCase().includes(q) ||
c.sans.some((s) => s.includes(q))
)
})
}, [search, statusFilter])
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Сертификаты" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Выпустить сертификат
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Alerts */}
{(expiring.length > 0 || expired.length > 0) && (
<div className="flex flex-col gap-2">
{expired.length > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-red-500/5 border border-red-500/20 px-4 py-3 text-sm">
<AlertCircleIcon className="size-5 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-600 dark:text-red-400">
{expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"}
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{expired.map((c) => c.name).join(", ")} требуют обновления
</p>
</div>
</div>
)}
{expiring.length > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<AlertTriangleIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
{expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней
</p>
<p className="text-muted-foreground text-xs mt-0.5">
{expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")}
</p>
</div>
</div>
)}
</div>
)}
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerCertificates.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Действующих", value: routerCertificates.filter((c) => c.status === "valid").length, icon: <BadgeCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Истекают", value: expiring.length, icon: <AlertTriangleIcon className="size-4 text-amber-500" /> },
{ label: "Истёкших", value: expired.length, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
{/* search */}
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, CN, эмитенту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* status filter */}
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "valid", "expired", "revoked"] as const).map((s) => (
<button key={s}
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{s === "all" ? "Все" : s === "valid" ? "Действующие" : s === "expired" ? "Истёкшие" : "Отозванные"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} сертификатов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_1fr_1fr_160px_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / CN</span>
<span>Сервер</span>
<span>Выпущен</span>
<div className="flex items-center gap-1"><CalendarIcon className="size-3" />Срок</div>
<div className="flex items-center gap-1"><KeyRoundIcon className="size-3" />Использование</div>
<span>Статус</span>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Сертификаты не найдены</p>
</div>
) : (
filtered.map((cert) => (
<CertRow
key={cert.id}
cert={cert}
expanded={expandedIds.has(cert.id)}
onToggle={() => toggleExpand(cert.id)}
/>
))
)}
</Card>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /certificate команды управления
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать CA",
lines: [
"/certificate add \\",
" name=my-ca \\",
" common-name=MyCA \\",
" key-size=4096 \\",
" days-valid=3650 \\",
" key-usage=key-cert-sign,crl-sign",
"/certificate sign my-ca",
],
},
{
title: "Выпустить сертификат",
lines: [
"/certificate add \\",
" name=router-cert \\",
" common-name=router.example.com \\",
" subject-alt-name=\\",
" IP:10.0.0.1 \\",
" key-size=2048 days-valid=365",
"/certificate sign router-cert \\",
" ca=my-ca",
],
},
{
title: "Статус и экспорт",
lines: [
"# Список:",
"/certificate print",
"",
"# Экспорт (PKCS12):",
"/certificate export-certificate \\",
" router-cert \\",
" export-passphrase=secret",
"",
"# Импорт:",
"/certificate import \\",
" file-name=cert.crt",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+356
View File
@@ -0,0 +1,356 @@
"use client"
import { useState, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
PlusIcon, SearchIcon, TagIcon, ServerIcon, FilterIcon,
ChevronRightIcon, CopyIcon, CheckIcon, TrashIcon, PencilIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { filters } from "@/lib/data"
// ─── types ────────────────────────────────────────────────────────────────────
type CommType = "standard" | "no-export" | "no-advertise" | "local-as" | "custom"
interface Community {
id: string
value: string // e.g. "65001:100"
name: string
description: string
type: CommType
filterIds: string[] // which filters use this community
serverCount: number
prefixCount: number
action: "permit" | "deny" | "local-pref" | "metric"
actionValue?: number // e.g. local-pref value
enabled: boolean
}
// ─── mock data ────────────────────────────────────────────────────────────────
const COMMUNITIES: Community[] = [
{
id: "c1", value: "65001:100", name: "youtube-bypass",
description: "Пометка для трафика YouTube — маршрутизация через SPB/FRA exit nodes",
type: "standard", filterIds: ["f1"], serverCount: 4, prefixCount: 842,
action: "local-pref", actionValue: 200, enabled: true,
},
{
id: "c2", value: "65001:200", name: "streaming-eu",
description: "EU-стриминг (Netflix, Twitch) — выход через FRA/AMS",
type: "standard", filterIds: ["f1", "f2"], serverCount: 3, prefixCount: 614,
action: "local-pref", actionValue: 180, enabled: true,
},
{
id: "c3", value: "65001:300", name: "cdn-bypass",
description: "Обход CDN-провайдеров через прямые пиринговые IX-точки",
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 4218,
action: "local-pref", actionValue: 210, enabled: true,
},
{
id: "c4", value: "65002:100", name: "cdn-secondary",
description: "Резервный CDN-путь при деградации основного",
type: "standard", filterIds: ["f3"], serverCount: 5, prefixCount: 1842,
action: "metric", actionValue: 50, enabled: true,
},
{
id: "c5", value: "65001:400", name: "office-direct",
description: "Office 365 / Teams — прямой выход без туннелирования",
type: "standard", filterIds: ["f5"], serverCount: 6, prefixCount: 882,
action: "permit", enabled: true,
},
{
id: "c6", value: "65001:500", name: "gaming-ll",
description: "Low-latency gaming — приоритет по минимальному RTT (Discord, Steam)",
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 1212,
action: "local-pref", actionValue: 250, enabled: true,
},
{
id: "c7", value: "65002:200", name: "gaming-ll-backup",
description: "Резервный путь для gaming-low-latency",
type: "standard", filterIds: ["f6"], serverCount: 4, prefixCount: 412,
action: "metric", actionValue: 80, enabled: true,
},
{
id: "c8", value: "65007:999", name: "school-block",
description: "Блокировка соцсетей для школьного сегмента LAN",
type: "standard", filterIds: ["f4"], serverCount: 2, prefixCount: 412,
action: "deny", enabled: false,
},
{
id: "c9", value: "no-export", name: "no-export",
description: "Не объявлять маршрут за пределы AS (RFC 1997)",
type: "no-export", filterIds: [], serverCount: 1, prefixCount: 0,
action: "permit", enabled: true,
},
{
id: "c10", value: "no-advertise", name: "no-advertise",
description: "Не передавать маршрут ни одному BGP-пиру (RFC 1997)",
type: "no-advertise", filterIds: [], serverCount: 1, prefixCount: 0,
action: "permit", enabled: true,
},
]
const TYPE_LABELS: Record<CommType, string> = {
"standard": "Стандартный",
"no-export": "No-export",
"no-advertise":"No-advertise",
"local-as": "Local-AS",
"custom": "Кастомный",
}
const ACTION_LABELS: Record<Community["action"], string> = {
"permit": "Permit",
"deny": "Deny",
"local-pref": "Local-pref",
"metric": "MED/Metric",
}
const ACTION_COLOR: Record<Community["action"], string> = {
"permit": "text-emerald-500",
"deny": "text-red-500",
"local-pref": "text-blue-500",
"metric": "text-amber-500",
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function CommunitiesPage() {
const [search, setSearch] = useState("")
const [typeFilter, setTypeFilter] = useState<CommType | "all">("all")
const [copied, setCopied] = useState<string | null>(null)
const [selected, setSelected] = useState<Community | null>(null)
const filtered = useMemo(() => {
const q = search.toLowerCase()
return COMMUNITIES.filter(c => {
const matchQ = !q || c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
const matchT = typeFilter === "all" || c.type === typeFilter
return matchQ && matchT
})
}, [search, typeFilter])
const handleCopy = (value: string) => {
navigator.clipboard.writeText(value).catch(() => {})
setCopied(value)
setTimeout(() => setCopied(null), 1500)
}
const filterName = (id: string) => filters.find(f => f.id === id)?.name ?? id
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Communities" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Добавить
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* ── summary ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Всего communities", value: String(COMMUNITIES.length) },
{ label: "Активных", value: String(COMMUNITIES.filter(c => c.enabled).length) },
{ label: "Стандартных", value: String(COMMUNITIES.filter(c => c.type === "standard").length) },
{ label: "Использует фильтры",value: String(new Set(COMMUNITIES.flatMap(c => c.filterIds)).size) },
].map(({ label, value }) => (
<Card key={label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-[1fr_320px] gap-5">
{/* ── main table ── */}
<div className="flex flex-col gap-3">
{/* toolbar */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[180px]">
<SearchIcon className="absolute left-2.5 top-2 size-4 text-muted-foreground" />
<Input className="pl-8 h-8 text-sm" placeholder="Поиск community…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<div className="flex gap-1">
{(["all", "standard", "no-export", "no-advertise", "custom"] as const).map(t => (
<Button key={t} size="sm" variant={typeFilter === t ? "default" : "outline"}
className="h-8 text-xs px-2.5" onClick={() => setTypeFilter(t)}>
{t === "all" ? "Все" : TYPE_LABELS[t] ?? t}
</Button>
))}
</div>
</div>
{/* list */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="text-left font-medium px-4 py-2.5">Community</th>
<th className="text-left font-medium px-4 py-2.5">Имя / описание</th>
<th className="text-left font-medium px-4 py-2.5">Тип</th>
<th className="text-left font-medium px-4 py-2.5">Действие</th>
<th className="text-right font-medium px-4 py-2.5">Маршрутов</th>
<th className="text-right font-medium px-4 py-2.5">Серверов</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{filtered.map(c => (
<tr
key={c.id}
onClick={() => setSelected(c)}
className={cn(
"border-b last:border-0 cursor-pointer hover:bg-muted/40 transition-colors",
selected?.id === c.id && "bg-primary/5",
!c.enabled && "opacity-50",
)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<TagIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{c.value}
</span>
<button
onClick={e => { e.stopPropagation(); handleCopy(c.value) }}
className="text-muted-foreground/40 hover:text-muted-foreground transition-colors"
>
{copied === c.value
? <CheckIcon className="size-3" />
: <CopyIcon className="size-3" />
}
</button>
</div>
</td>
<td className="px-4 py-2.5">
<p className="font-medium text-xs">{c.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">{c.description}</p>
</td>
<td className="px-4 py-2.5">
<span className="text-xs text-muted-foreground">{TYPE_LABELS[c.type]}</span>
</td>
<td className="px-4 py-2.5">
<span className={cn("text-xs font-medium", ACTION_COLOR[c.action])}>
{ACTION_LABELS[c.action]}{c.actionValue !== undefined ? ` ${c.actionValue}` : ""}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{c.prefixCount > 0 ? c.prefixCount.toLocaleString() : "—"}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<ServerIcon className="size-3 text-muted-foreground" />
<span className="font-mono text-xs">{c.serverCount}</span>
</div>
</td>
<td className="px-4 py-2.5">
<ChevronRightIcon className="size-4 text-muted-foreground/40" />
</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<TagIcon className="size-8 opacity-30" />
<p className="text-sm">Ничего не найдено</p>
</div>
)}
</div>
</Card>
</div>
{/* ── detail panel ── */}
{selected ? (
<Card className="h-fit sticky top-0">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
selected.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-muted text-muted-foreground"
)}>
{selected.enabled ? "Активен" : "Выключен"}
</span>
</div>
<CardTitle className="text-sm font-mono">{selected.value}</CardTitle>
<CardDescription className="text-xs mt-0.5">{selected.name}</CardDescription>
</div>
<div className="flex gap-1 shrink-0">
<Button size="sm" variant="ghost" className="size-7 p-0"><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className="size-7 p-0 text-destructive hover:text-destructive"><TrashIcon className="size-3.5" /></Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-xs text-muted-foreground">{selected.description}</p>
<div className="space-y-2">
{[
["Тип", TYPE_LABELS[selected.type]],
["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`],
["Маршрутов", selected.prefixCount > 0 ? selected.prefixCount.toLocaleString() : "—"],
["Серверов", String(selected.serverCount)],
].map(([k, v]) => (
<div key={k} className="flex justify-between gap-2">
<span className="text-xs text-muted-foreground">{k}</span>
<span className="text-xs font-medium">{v}</span>
</div>
))}
</div>
{selected.filterIds.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1.5">
<FilterIcon className="size-3" />Использующие фильтры
</p>
<div className="flex flex-wrap gap-1.5">
{selected.filterIds.map(fid => (
<span key={fid} className="text-[11px] bg-muted px-2 py-0.5 rounded font-mono">
{filterName(fid)}
</span>
))}
</div>
</div>
)}
<div className="pt-2 border-t flex flex-col gap-2">
<Button size="sm" variant="outline" className="w-full justify-start gap-2" onClick={() => handleCopy(selected.value)}>
{copied === selected.value ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
Скопировать значение
</Button>
</div>
</CardContent>
</Card>
) : (
<Card className="h-fit">
<CardContent className="flex flex-col items-center justify-center py-12 gap-3 text-center">
<TagIcon className="size-8 text-muted-foreground/30" />
<p className="text-xs text-muted-foreground">Выберите community<br />для просмотра деталей</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
)
}
+473
View File
@@ -0,0 +1,473 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { routerContainers, servers } from "@/lib/data"
import type { RouterContainer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
BoxIcon, PlayIcon, StopCircleIcon, SearchIcon,
MoreHorizontalIcon, Trash2Icon, PencilIcon, PowerIcon,
CodeXmlIcon, CopyIcon, CheckIcon, ActivityIcon, ServerIcon,
TerminalIcon, AlertCircleIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
function serverFor(id: string) {
return servers.find((s) => s.id === id)
}
function statusConfig(status: RouterContainer["status"]) {
return {
running: {
dot: "bg-emerald-500 animate-pulse",
badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
label: "Running",
},
stopped: {
dot: "bg-muted-foreground",
badge: "bg-muted/50 text-muted-foreground border-border",
label: "Stopped",
},
error: {
dot: "bg-red-500 animate-pulse",
badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
label: "Error",
},
}[status]
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateContainerRsc(c: RouterContainer): string {
const srv = serverFor(c.serverId)
const lines: string[] = []
lines.push(`# RouterOS Container — ${c.name}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
lines.push(`# Образ: ${c.image}:${c.tag}`)
lines.push(`# RouterOS 7.4+ · /container`)
lines.push(``)
// interface
for (const iface of c.interfaces) {
lines.push(`/interface/veth/add name=${iface} address=172.17.0.2/24 gateway=172.17.0.1`)
}
lines.push(``)
// envs
if (c.envs.length > 0) {
lines.push(`/container/envs/add name=${c.name}-envs \\`)
for (const { key, value } of c.envs) {
lines.push(` ${key}="${value}" \\`)
}
lines.push(``)
}
// mounts
for (const m of c.mounts) {
lines.push(`/container/mounts/add name=${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)} \\`)
if (m.src) lines.push(` src=${m.src} \\`)
lines.push(` dst=${m.dst}`)
lines.push(``)
}
// container
lines.push(`/container/add \\`)
lines.push(` remote-image=${c.image}:${c.tag} \\`)
lines.push(` interface=${c.interfaces[0] ?? "veth-container"} \\`)
if (c.envs.length > 0) lines.push(` envlist=${c.name}-envs \\`)
if (c.mounts.length > 0) lines.push(` mounts=${c.mounts.map((m) => `${c.name}-mount-${m.dst.replace(/\//g, "-").slice(1)}`).join(",")} \\`)
if (c.cmd) lines.push(` cmd="${c.cmd}" \\`)
if (c.startOnBoot) lines.push(` start-on-boot=yes \\`)
if (c.comment) lines.push(` comment="${c.comment}" \\`)
lines.push(` logging=yes`)
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, container, onClose }: {
open: boolean; container: RouterContainer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => container ? generateContainerRsc(container) : "", [container])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт Container</SheetTitle>
<SheetDescription>RouterOS 7.4+ · /container · /interface/veth</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── Container card ───────────────────────────────────────────────────────────
function ContainerCard({
container,
onExport,
}: {
container: RouterContainer
onExport: () => void
}) {
const srv = serverFor(container.serverId)
const cfg = statusConfig(container.status)
return (
<Card className="overflow-hidden">
<div className="px-4 py-3 border-b flex items-center justify-between gap-2 bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<span className={cn("size-2 rounded-full shrink-0", cfg.dot)} />
<span className="font-mono font-semibold text-sm truncate">{container.name}</span>
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded border shrink-0", cfg.badge)}>
{cfg.label}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7 shrink-0">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
{container.status === "running" ? (
<DropdownMenuItem><StopCircleIcon className="size-4 text-amber-500" />Остановить</DropdownMenuItem>
) : (
<DropdownMenuItem><PlayIcon className="size-4 text-emerald-500" />Запустить</DropdownMenuItem>
)}
<DropdownMenuItem><TerminalIcon className="size-4" />Логи</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />Перезапустить</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardContent className="px-4 py-3 flex flex-col gap-3">
{/* image */}
<div className="flex items-center gap-2">
<BoxIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className="font-mono text-xs text-foreground/80">
{container.image}:<span className="text-muted-foreground">{container.tag}</span>
</span>
</div>
{/* server */}
{srv && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ServerIcon className="size-3.5 shrink-0" />
<Flag code={srv.country} size={12} />
<span className="font-mono">{srv.name}</span>
</div>
)}
{/* uptime + stats */}
{container.status === "running" && (
<div className="flex items-center gap-4 text-xs text-muted-foreground border-t pt-2.5">
{container.uptime && (
<div className="flex items-center gap-1">
<ActivityIcon className="size-3 text-emerald-500" />
<span>{container.uptime}</span>
</div>
)}
{container.cpu !== undefined && (
<div>
<span className="text-muted-foreground">CPU </span>
<span className={cn("font-mono font-medium",
container.cpu > 50 ? "text-amber-500" : "text-foreground")}>
{container.cpu}%
</span>
</div>
)}
{container.memMb !== undefined && (
<div>
<span className="text-muted-foreground">RAM </span>
<span className="font-mono font-medium">{container.memMb} МБ</span>
</div>
)}
</div>
)}
{/* interfaces */}
{container.interfaces.length > 0 && (
<div className="flex flex-wrap gap-1">
{container.interfaces.map((i) => (
<span key={i} className="text-[10px] font-mono px-1.5 py-0.5 bg-muted border rounded text-muted-foreground">
{i}
</span>
))}
</div>
)}
{/* mounts */}
{container.mounts.length > 0 && (
<div className="flex flex-col gap-1">
{container.mounts.map((m, idx) => (
<div key={idx} className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
<span className="text-muted-foreground/40"></span>
<span>{m.dst}</span>
{m.src && <><span className="text-muted-foreground/40"></span><span>{m.src}</span></>}
</div>
))}
</div>
)}
{container.comment && (
<p className="text-xs text-muted-foreground border-t pt-2">{container.comment}</p>
)}
</CardContent>
</Card>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function ContainersPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<RouterContainer["status"] | "all">("all")
const [exportContainer, setExportContainer] = useState<RouterContainer | null>(null)
const filtered = useMemo(() => {
return routerContainers.filter((c) => {
if (statusFilter !== "all" && c.status !== statusFilter) return false
if (!search) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) ||
(serverFor(c.serverId)?.name.toLowerCase().includes(q) ?? false)
)
})
}, [search, statusFilter])
const running = routerContainers.filter((c) => c.status === "running").length
const stopped = routerContainers.filter((c) => c.status === "stopped").length
const errors = routerContainers.filter((c) => c.status === "error").length
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Контейнеры" }]}
actions={
<Button size="sm">
<BoxIcon className="size-4" />Новый контейнер
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Всего", value: routerContainers.length, icon: <BoxIcon className="size-4 text-muted-foreground" /> },
{ label: "Running", value: running, icon: <PlayIcon className="size-4 text-emerald-500" /> },
{ label: "Stopped", value: stopped, icon: <StopCircleIcon className="size-4 text-muted-foreground" /> },
{ label: "Ошибок", value: errors, icon: <AlertCircleIcon className="size-4 text-red-500" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-violet-500/5 border border-violet-500/20 px-4 py-3 text-sm">
<BoxIcon className="size-5 text-violet-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-violet-600 dark:text-violet-400">Контейнеры доступны с RouterOS 7.4+</p>
<p className="text-muted-foreground text-xs mt-0.5">
Поддерживаются Docker-совместимые образы. Требуется установка пакета <code className="font-mono bg-muted px-1 rounded">container</code>.
Интерфейсы veth создаются автоматически.
</p>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, образу, серверу…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{(["all", "running", "stopped", "error"] as const).map((s) => (
<button key={s}
onClick={() => setStatusFilter(s)}
className={cn(
"px-3 py-1 text-xs rounded whitespace-nowrap transition-colors",
statusFilter === s
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}>
{s === "all" ? "Все" : s === "running" ? "Running" : s === "stopped" ? "Stopped" : "Error"}
</button>
))}
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} контейнеров</span>
</div>
{/* Grid */}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<BoxIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Контейнеры не найдены</p>
<p className="text-xs mt-1">Добавьте первый контейнер или измените фильтр</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((c) => (
<ContainerCard
key={c.id}
container={c}
onExport={() => setExportContainer(c)}
/>
))}
</div>
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7.4+ · /container быстрые команды
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Установка пакета",
lines: [
"# Скачать пакет container:",
"# mikrotik.com → Software",
"",
"/system/package/install",
" container",
"",
"# Перезагрузить:",
"/system/reboot",
],
},
{
title: "Создать контейнер",
lines: [
"# veth интерфейс:",
"/interface/veth/add \\",
" name=veth-nginx \\",
" address=172.17.0.2/24 \\",
" gateway=172.17.0.1",
"",
"# Контейнер:",
"/container/add \\",
" remote-image=nginx:alpine \\",
" interface=veth-nginx",
],
},
{
title: "Управление",
lines: [
"# Список:",
"/container/print",
"",
"# Запуск:",
"/container/start 0",
"",
"# Остановка:",
"/container/stop 0",
"",
"# Логи:",
"/container/shell 0",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<ExportSheet
open={!!exportContainer}
container={exportContainer}
onClose={() => setExportContainer(null)}
/>
</div>
)
}
+242
View File
@@ -0,0 +1,242 @@
import { PageHeader } from "@/components/page-header"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusDot } from "@/components/status-dot"
import { StatusBadge } from "@/components/status-badge"
import { Sparkline } from "@/components/sparkline"
import { LatencyChart } from "@/components/dashboard/latency-chart"
import { BandwidthChart } from "@/components/dashboard/bandwidth-chart"
import { servers, pingProbes, systemEvents, dashLatency, traffic } from "@/lib/data"
import { Flag } from "@/components/flag"
import { AlertCircleIcon, AlertTriangleIcon, InfoIcon, FilterIcon, DownloadIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
function StatCard({
label, value, unit, delta, deltaDir, spark, sparkColor,
}: {
label: string; value: string; unit?: string; delta?: string
deltaDir?: "up" | "down"; spark?: number[]; sparkColor?: string
}) {
return (
<Card className="relative overflow-hidden">
<CardContent className="pt-5 pb-4 px-5">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<div className="flex items-baseline gap-1.5 mt-1">
<span className="text-3xl font-semibold tracking-tight tabular-nums">{value}</span>
{unit && <span className="text-sm text-muted-foreground">{unit}</span>}
</div>
{delta && (
<p className={`text-xs mt-1 flex items-center gap-1 ${deltaDir === "up" ? "text-emerald-600 dark:text-emerald-400" : deltaDir === "down" ? "text-red-500 dark:text-red-400" : "text-muted-foreground"}`}>
{delta}
</p>
)}
{spark && spark.length > 1 && (
<div className="absolute right-4 bottom-4 opacity-60">
<Sparkline data={spark} width={80} height={32} color={sparkColor ?? "currentColor"} filled />
</div>
)}
</CardContent>
</Card>
)
}
export default function DashboardPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Обзор" }, { label: "Дашборд" }]}
actions={
<>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto">
<div className="p-6 flex flex-col gap-6">
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Серверы онлайн" value="6" unit="/ 7"
delta="+1 со вчерашнего" deltaDir="up"
spark={[5,5,6,6,5,6,6,6,7,6,6,6,6,6,6,6,6]}
sparkColor="var(--chart-line-1)"
/>
<StatCard
label="Активные фильтры" value="5" unit="/ 6"
delta="2.4к доменов синхронизировано" deltaDir="up"
spark={[3,4,4,5,4,5,5,5,5,5,5,5,5,5,5]}
sparkColor="var(--chart-line-2)"
/>
<StatCard
label="BGP-префиксы" value="8 432"
delta="+412 в последнем обновлении" deltaDir="up"
spark={[7800,7900,8000,8100,8050,8120,8200,8240,8300,8350,8380,8400,8420,8430,8432]}
sparkColor="var(--chart-line-4)"
/>
<StatCard
label="Активные алерты" value="4"
delta="2 критических · 2 предупреждения" deltaDir="down"
spark={[1,2,2,3,3,4,5,4,4,4,3,3,4,4,4]}
sparkColor="var(--chart-5)"
/>
</div>
{/* Latency chart + Events */}
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Задержка до серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Последние 60 минут · ping от монитора серверы MikroTik</p>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<LatencyChart series={dashLatency} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Последние события</CardTitle>
<Button variant="ghost" size="sm" className="text-xs h-7">Все </Button>
</div>
<p className="text-sm text-muted-foreground">Система и BGP-активность</p>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="divide-y divide-border">
{systemEvents.map((e) => (
<div key={e.id} className="grid grid-cols-[20px_1fr_auto] gap-3 px-5 py-3 items-start">
<div className="mt-0.5">
{e.sev === "destructive" && <AlertCircleIcon className="size-4 text-red-500" />}
{e.sev === "warning" && <AlertTriangleIcon className="size-4 text-amber-500" />}
{e.sev === "info" && <InfoIcon className="size-4 text-blue-500" />}
</div>
<div>
<p className="text-[13px] font-medium leading-tight">{e.title}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">{e.message}</p>
</div>
<span className="text-[11px] font-mono text-muted-foreground">{e.when}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Bandwidth + Server status */}
<div className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Пропускная способность</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">Суммарный RX / TX по всем серверам</p>
</div>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-foreground/80 rounded inline-block" />RX 318 Мбит/с</span>
<span className="flex items-center gap-1.5"><span className="w-3 h-0.5 bg-blue-500 rounded inline-block" />TX 244 Мбит/с</span>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 px-3">
<BandwidthChart rx={traffic.rx} tx={traffic.tx} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Состояние серверов</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">7 узлов MikroTik</p>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs">Управление </Button>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="divide-y divide-border">
{servers.slice(0, 5).map((s) => (
<div key={s.id} className="grid grid-cols-[10px_1fr_auto_auto] gap-3 px-5 py-2.5 items-center">
<StatusDot status={s.status} pulse />
<div>
<p className="text-[13px] font-medium leading-tight">{s.name}</p>
<p className="text-[11px] font-mono text-muted-foreground flex items-center gap-1">
<Flag code={s.country} className="not-mono" />{s.host} · {s.site} · {s.asn}
</p>
</div>
<span className={`text-xs font-mono ${s.latency == null ? "text-red-500" : s.latency > 60 ? "text-amber-500" : "text-muted-foreground"}`}>
{s.latency == null ? "недоступен" : `${s.latency}мс`}
</span>
<StatusBadge status={s.status} />
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Ping probes table */}
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Активные пробы</CardTitle>
<p className="text-sm text-muted-foreground mt-0.5">8 ping-зондов · опрос раз в секунду</p>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs">Открыть монитор </Button>
</div>
</CardHeader>
<CardContent className="pt-0 px-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-2.5">Проба</th>
<th className="text-left font-medium px-4 py-2.5">Цель</th>
<th className="text-left font-medium px-4 py-2.5">Фильтр</th>
<th className="text-right font-medium px-4 py-2.5">RTT</th>
<th className="text-right font-medium px-4 py-2.5">Потери</th>
<th className="text-left font-medium px-4 py-2.5 w-36">60с</th>
<th className="text-left font-medium px-4 py-2.5">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pingProbes.map((p) => {
const sparkColor = p.status === "down" ? "hsl(0 84% 60%)" : p.status === "warn" ? "hsl(32 94% 44%)" : "hsl(142 76% 36%)"
return (
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-2.5 font-medium">{p.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{p.target}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{p.filter}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-right">{p.rtt == null ? "—" : `${p.rtt} мс`}</td>
<td className={`px-4 py-2.5 font-mono text-right ${p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground"}`}>
{p.loss}%
</td>
<td className="px-4 py-2.5">
<Sparkline data={p.series} width={120} height={24} color={sparkColor} />
</td>
<td className="px-4 py-2.5">
<StatusBadge status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} />
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
"use client"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { domains } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
export default function DomainsPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "Домены" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить домен</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Домены</h1>
<p className="text-sm text-muted-foreground mt-1">
Отслеживаемые домены для BGP-фильтрации синхронизируются в address-list MikroTik
</p>
</div>
<DataTable
data={domains}
searchPlaceholder="Поиск по домену…"
searchKeys={["domain", "asn", "filter"]}
columns={[
{
key: "domain",
label: "Домен",
render: (d) => <span className="font-medium">{d.domain}</span>,
},
{
key: "resolvedIp",
label: "Resolved IP",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.resolvedIp}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs">{d.asn}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => (
<span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>
),
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+864
View File
@@ -0,0 +1,864 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { greTunnels, grePools, servers } from "@/lib/data"
import type { GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion } from "@/lib/data"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup,
} from "@/components/ui/dropdown-menu"
import { Flag } from "@/components/flag"
import {
PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon,
LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon,
EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon,
CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon,
} from "lucide-react"
// ─── label maps ─────────────────────────────────────────────────────────────
const ENC_LABELS: Record<IpsecEncAlg, string> = { "aes-128": "AES-128", "aes-192": "AES-192", "aes-256": "AES-256" }
const AUTH_LABELS: Record<IpsecAuthAlg, string> = { sha1: "SHA-1", sha256: "SHA-256", sha512: "SHA-512" }
const DH_LABELS: Record<IpsecDhGroup, string> = {
modp1024: "DH-2 (1024)", modp2048: "DH-14 (2048)", modp4096: "DH-16 (4096)",
ecp256: "ECP-256", ecp384: "ECP-384", ecp521: "ECP-521",
}
const IKE_LABELS: Record<IkeVersion, string> = { ikev1: "IKEv1", ikev2: "IKEv2" }
const ENC_ROS: Record<IpsecEncAlg, string> = { "aes-128": "aes-128-cbc", "aes-192": "aes-192-cbc", "aes-256": "aes-256-cbc" }
const AUTH_ROS: Record<IpsecAuthAlg, string> = { sha1: "sha1", sha256: "sha256", sha512: "sha512" }
const STATUS_MAP: Record<GreStatus, { label: string; dot: string }> = {
up: { label: "Up", dot: "bg-emerald-500" },
degraded: { label: "Degraded", dot: "bg-amber-500" },
down: { label: "Down", dot: "bg-red-500" },
}
const serverById = Object.fromEntries(servers.map((s) => [s.id, s]))
const poolById = Object.fromEntries(grePools.map((p) => [p.id, p]))
// ─── RouterOS code generator ─────────────────────────────────────────────────
function generateRosCommands(t: GreTunnel): string {
const lines: string[] = []
const srv = serverById[t.serverId]
lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`)
lines.push(`# Сгенерировано RouterLists · ${new Date().toLocaleDateString("ru")}`)
lines.push("")
// GRE interface
lines.push("# ── GRE-интерфейс ────────────────────────────────────────────")
const greParts = [
`/interface gre add`,
` name=${t.name}`,
` remote-address=${t.remoteAddress}`,
t.localAddress !== "0.0.0.0" ? ` local-address=${t.localAddress}` : null,
t.ipsec ? ` ipsec-secret="${t.ipsec.secret}"` : null,
` mtu=${t.mtu}`,
t.keepaliveInterval > 0 ? ` keepalive=${t.keepaliveInterval}s,${t.keepaliveRetries}` : ` keepalive=0`,
` dscp=${t.dscp}`,
` clamp-tcp-mss=${t.clampTcpMss ? "yes" : "no"}`,
` allow-fast-path=${t.allowFastPath ? "yes" : "no"}`,
t.comment ? ` comment="${t.comment}"` : null,
!t.enabled ? ` disabled=yes` : null,
].filter(Boolean) as string[]
lines.push(greParts.join(" \\\n"))
// Inner IP
lines.push("")
lines.push("# ── Внутренний IP ────────────────────────────────────────────")
lines.push(`/ip address add \\`)
lines.push(` address=${t.localInnerIp} \\`)
lines.push(` interface=${t.name}`)
// IPsec manual equivalent
if (t.ipsec) {
const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1"
const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none"
lines.push("")
lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────")
lines.push("")
lines.push(`/ip ipsec peer add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` address=${t.remoteAddress} \\`)
lines.push(` exchange-mode=${ikeMode} \\`)
lines.push(` auth-method=pre-shared-key \\`)
lines.push(` secret="${t.ipsec.secret}"`)
lines.push("")
lines.push(`/ip ipsec proposal add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`)
lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`)
lines.push(` pfs-group=${pfsGroup} \\`)
lines.push(` lifetime=${t.ipsec.lifetime}`)
lines.push("")
lines.push(`/ip ipsec policy add \\`)
lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`)
lines.push(` dst-address=${t.remoteAddress}/32 \\`)
lines.push(` proposal=${t.name} \\`)
lines.push(` tunnel=yes`)
}
return lines.join("\n")
}
// ─── small ui helpers ────────────────────────────────────────────────────────
function TunnelStatus({ status }: { status: GreStatus }) {
const s = STATUS_MAP[status]
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={`size-1.5 rounded-full ${s.dot}`} />
{s.label}
</span>
)
}
function IpsecBadge({ secured }: { secured: boolean }) {
return secured ? (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
<LockIcon className="size-3" /> IPsec
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-medium border rounded px-2 py-0.5 bg-muted text-muted-foreground border-border">
<LockOpenIcon className="size-3" /> Открытый
</span>
)
}
function Field({ label, hint, required, children }: {
label: string; hint?: string; required?: boolean; children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors ${checked ? "bg-primary" : "bg-input"}`}
>
<span className={`pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"}`} />
</button>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function SegmentedControl<T extends string>({ value, onChange, options }: {
value: T; onChange: (v: T) => void; options: { value: T; label: string }[]
}) {
return (
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 w-fit">
{options.map((o) => (
<button key={o.value} type="button" onClick={() => onChange(o.value)}
className={`px-3 py-1 text-sm rounded transition-colors ${value === o.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{o.label}
</button>
))}
</div>
)
}
// ─── default form states ─────────────────────────────────────────────────────
const defaultTunnelForm = {
name: "", serverId: "", localAddress: "", remoteAddress: "",
poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true,
ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false,
ipsecIkeVersion: "ikev2" as IkeVersion,
ipsecEncAlg: "aes-256" as IpsecEncAlg,
ipsecAuthAlg: "sha256" as IpsecAuthAlg,
ipsecDhGroup: "modp2048" as IpsecDhGroup,
ipsecLifetime: "1d 00:00:00", ipsecPfs: true,
mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10,
dscp: "inherit", clampTcpMss: true, allowFastPath: true,
showAdvanced: false,
}
const defaultPoolForm = { name: "", cidr: "", comment: "" }
type TabFilter = "all" | "up" | "ipsec" | "plain"
type PageTab = "tunnels" | "pools"
// ════════════════════════════════════════════════════════════════════════════
export default function GrePage() {
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
const [search, setSearch] = useState("")
const [tunnelOpen, setTunnelOpen] = useState(false)
const [poolOpen, setPoolOpen] = useState(false)
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
const [copied, setCopied] = useState(false)
const [tForm, setTForm] = useState(defaultTunnelForm)
const [pForm, setPForm] = useState(defaultPoolForm)
const setT = <K extends keyof typeof defaultTunnelForm>(k: K, v: (typeof defaultTunnelForm)[K]) =>
setTForm((f) => ({ ...f, [k]: v }))
const filtered = useMemo(() => {
return greTunnels.filter((t) => {
if (tabFilter === "up" && t.status !== "up") return false
if (tabFilter === "ipsec" && !t.ipsec) return false
if (tabFilter === "plain" && t.ipsec) return false
if (!search) return true
const q = search.toLowerCase()
return (
t.name.toLowerCase().includes(q) ||
t.remoteAddress.includes(q) ||
t.localInnerIp.includes(q) ||
serverById[t.serverId]?.name.toLowerCase().includes(q)
)
})
}, [tabFilter, search])
const upCount = greTunnels.filter((t) => t.status === "up").length
const ipsecCount = greTunnels.filter((t) => t.ipsec).length
const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [
{ value: "all", label: "Все", count: greTunnels.length },
{ value: "up", label: "Активные", count: upCount },
{ value: "ipsec", label: "С IPsec", count: ipsecCount },
{ value: "plain", label: "Без IPsec", count: greTunnels.length - ipsecCount },
]
function handleCopy(code: string) {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
actions={
<>
<Button variant="outline" size="sm"><RefreshCwIcon className="size-4" />Обновить статус</Button>
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
<PlusIcon className="size-4" />Добавить туннель
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* Legacy banner */}
<div className="flex items-start gap-3 rounded-lg bg-amber-500/5 border border-amber-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-amber-600 dark:text-amber-400">
В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec
</p>
<p className="text-muted-foreground text-xs mt-0.5">
WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305).
GRE-туннели остаются поддерживаемыми, но WireGuard предпочтительный выбор для новых развёртываний.
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
{[
{ label: "Всего туннелей", value: greTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активно", value: upCount, icon: <ShieldCheckIcon className="size-4 text-emerald-500" /> },
{ label: "Защищены IPsec", value: ipsecCount, icon: <LockIcon className="size-4 text-violet-400" /> },
{ label: "IP-пулов", value: grePools.length, icon: <NetworkIcon className="size-4 text-sky-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Page tabs */}
<div className="flex items-center gap-1 border-b">
{(["tunnels", "pools"] as PageTab[]).map((tab) => (
<button key={tab} onClick={() => setPageTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${pageTab === tab ? "border-foreground text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"}`}>
{tab === "tunnels" ? "Туннели" : "IP-пулы"}
</button>
))}
</div>
{/* ── Tunnels ── */}
{pageTab === "tunnels" && (
<Card>
<div className="flex items-center gap-3 px-5 py-3 border-b flex-wrap">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
{tunnelTabs.map((t) => (
<button key={t.value} onClick={() => setTabFilter(t.value)}
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors ${tabFilter === t.value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>
{t.label}
<span className="text-xs tabular-nums opacity-60">{t.count}</span>
</button>
))}
</div>
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[220px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, IP…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Интерфейс / Сервер</th>
<th className="text-left font-medium px-4 py-3">Эндпоинты</th>
<th className="text-left font-medium px-4 py-3">Внутренний IP</th>
<th className="text-left font-medium px-4 py-3">Пул</th>
<th className="text-left font-medium px-4 py-3">IPsec</th>
<th className="text-left font-medium px-4 py-3">Шифрование</th>
<th className="text-center font-medium px-4 py-3">MTU</th>
<th className="text-left font-medium px-4 py-3">Keepalive</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="w-20 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((t) => {
const srv = serverById[t.serverId]
const pool = poolById[t.poolId]
return (
<tr key={t.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3">
<p className="font-medium font-mono text-[13px]">{t.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
{srv && <Flag code={srv.country} />}
{srv?.name ?? t.serverId}
</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">
{t.localAddress === "0.0.0.0" ? <span className="text-muted-foreground">авто</span> : t.localAddress}
</p>
<p className="font-mono text-xs text-muted-foreground"> {t.remoteAddress}</p>
</td>
<td className="px-4 py-3">
<p className="font-mono text-xs">{t.localInnerIp}</p>
<p className="font-mono text-xs text-muted-foreground">{t.remoteInnerIp}</p>
</td>
<td className="px-4 py-3">
<span className="text-xs text-muted-foreground font-mono">{pool?.name ?? "—"}</span>
</td>
<td className="px-4 py-3"><IpsecBadge secured={!!t.ipsec} /></td>
<td className="px-4 py-3">
{t.ipsec ? (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-mono">{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]}</span>
<span className="text-xs text-muted-foreground font-mono">
{DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
</span>
</div>
) : <span className="text-xs text-muted-foreground"></span>}
</td>
<td className="px-4 py-3 text-center font-mono text-xs">{t.mtu}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
</td>
<td className="px-4 py-3"><TunnelStatus status={t.status} /></td>
{/* actions */}
<td className="px-3 py-3">
<div className="flex items-center gap-1 justify-end">
{/* Code preview button */}
<Button
variant="ghost" size="icon" className="size-7"
title="Предпросмотр кода RouterOS"
onClick={() => setCodePreviewTunnel(t)}
>
<CodeXmlIcon className="size-3.5" />
</Button>
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>{t.name}</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setCodePreviewTunnel(t)}>
<CodeXmlIcon className="size-4" /> Просмотр кода
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" /> Редактировать
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{t.enabled ? "Выключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" /> Удалить туннель
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
)}
{/* ── IP Pools ── */}
{pageTab === "pools" && (
<Card>
<div className="flex items-center justify-between px-5 py-3 border-b">
<span className="text-sm text-muted-foreground">{grePools.length} пула</span>
<Button size="sm" variant="outline" onClick={() => { setPForm(defaultPoolForm); setPoolOpen(true) }}>
<PlusIcon className="size-4" />Добавить пул
</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Имя пула</th>
<th className="text-left font-medium px-4 py-3">Диапазон CIDR</th>
<th className="text-right font-medium px-4 py-3">Назначено /30</th>
<th className="text-right font-medium px-4 py-3">Доступно /30</th>
<th className="text-left font-medium px-4 py-3">Использование</th>
<th className="text-left font-medium px-4 py-3">Назначение</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{grePools.map((pool) => {
const pct = Math.round((pool.allocated / pool.total) * 100)
return (
<tr key={pool.id} className="hover:bg-muted/40 transition-colors">
<td className="px-5 py-3 font-mono text-[13px] font-medium">{pool.name}</td>
<td className="px-4 py-3 font-mono text-xs">{pool.cidr}</td>
<td className="px-4 py-3 text-right tabular-nums">{pool.allocated}</td>
<td className="px-4 py-3 text-right tabular-nums text-muted-foreground">{pool.total - pool.allocated}</td>
<td className="px-4 py-3 min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${pct > 80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground tabular-nums w-8 text-right">{pct}%</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{pool.comment}</td>
<td className="px-3 py-3">
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem><PencilIcon className="size-4" /> Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" /> Удалить пул</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="border-t px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">Назначения по пулам</p>
<div className="flex flex-col gap-3">
{grePools.map((pool) => {
const poolTunnels = greTunnels.filter((t) => t.poolId === pool.id)
return (
<div key={pool.id}>
<p className="text-xs font-mono font-medium mb-1.5">{pool.name}</p>
<div className="flex flex-wrap gap-2">
{poolTunnels.map((t) => (
<div key={t.id} className="flex items-center gap-2 border border-border rounded-md px-3 py-1.5 bg-muted/30 text-xs">
<span className={`size-1.5 rounded-full ${STATUS_MAP[t.status].dot}`} />
<span className="font-mono font-medium">{t.name}</span>
<span className="text-muted-foreground">{t.localInnerIp} {t.remoteInnerIp}</span>
{t.ipsec && <LockIcon className="size-3 text-emerald-400" />}
</div>
))}
</div>
</div>
)
})}
</div>
</div>
</Card>
)}
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">RouterOS 7.20+ параметры GRE-интерфейса</p>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-xs font-mono">
{[
["/interface gre add", ""],
[" name=", "имя интерфейса"],
[" remote-address=", "IP удалённого конца (обязателен)"],
[" local-address=", "0.0.0.0 = авто"],
[" ipsec-secret=", "PSK → auto peer+policy+proposal"],
[" mtu=", "по умолчанию 1476"],
[" keepalive=", "10s,10 (интервал,попытки)"],
[" dscp=", "inherit | 0-63"],
[" clamp-tcp-mss=", "yes | no"],
[" allow-fast-path=", "yes | no"],
["/ip address add", ""],
[" address=x.x.x.x/30", "внутренний IP туннеля"],
[" interface=<name>", ""],
].map(([cmd, desc], i) => (
<div key={i} className="flex gap-2 py-0.5">
<span className="text-foreground/70 shrink-0">{cmd}</span>
{desc && <span className="text-muted-foreground"># {desc}</span>}
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */}
<Sheet open={!!codePreviewTunnel} onOpenChange={(open) => { if (!open) setCodePreviewTunnel(null) }}>
<SheetContent side="right" className="w-full sm:max-w-2xl flex flex-col gap-0 p-0">
{codePreviewTunnel && (() => {
const code = generateRosCommands(codePreviewTunnel)
return (
<>
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle className="font-mono">{codePreviewTunnel.name}</SheetTitle>
<SheetDescription>Команды RouterOS 7.20+ для создания туннеля</SheetDescription>
</div>
<Button
variant="outline" size="sm"
className="shrink-0 gap-1.5"
onClick={() => handleCopy(code)}
>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" /> Скопировано</>
: <><CopyIcon className="size-3.5" /> Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{/* meta strip */}
<div className="flex flex-wrap gap-3 px-6 py-3 border-b bg-muted/30 text-xs">
<span className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${STATUS_MAP[codePreviewTunnel.status].dot}`} />
{STATUS_MAP[codePreviewTunnel.status].label}
</span>
<span className="text-muted-foreground">·</span>
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
<span className="text-muted-foreground">·</span>
<span className="font-mono">{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} {codePreviewTunnel.remoteAddress}</span>
{codePreviewTunnel.ipsec && (
<>
<span className="text-muted-foreground">·</span>
<span className="flex items-center gap-1 text-emerald-400"><LockIcon className="size-3" /> IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]}</span>
</>
)}
</div>
{/* code block */}
<pre className="px-6 py-5 text-xs font-mono leading-relaxed text-foreground/90 whitespace-pre overflow-x-auto select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isSection = isComment && line.includes("──")
const isKey = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isSection ? "text-muted-foreground/60"
: isComment ? "text-muted-foreground"
: isKey ? "text-sky-400/90"
: "text-foreground"
}>
{line}
{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1 gap-1.5" onClick={() => handleCopy(code)}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать команды"}
</Button>
</SheetFooter>
</>
)
})()}
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */}
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый GRE-туннель</SheetTitle>
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
</Field>
<Field label="Сервер (MikroTik)" required>
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать сервер</option>
{servers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
</select>
</Field>
<Field label="Комментарий">
<Input placeholder="Описание туннеля" value={tForm.comment} onChange={(e) => setT("comment", e.target.value)} />
</Field>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Включён</span>
<Toggle checked={tForm.enabled} onChange={(v) => setT("enabled", v)} />
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Эндпоинты</SectionTitle>
<Field label="Локальный адрес" hint="Оставьте пустым или 0.0.0.0 для автоопределения">
<Input className="font-mono" placeholder="0.0.0.0" value={tForm.localAddress} onChange={(e) => setT("localAddress", e.target.value)} />
</Field>
<Field label="Удалённый адрес" required hint="Внешний IP удалённого MikroTik">
<Input className="font-mono" placeholder="203.0.113.1" value={tForm.remoteAddress} onChange={(e) => setT("remoteAddress", e.target.value)} />
</Field>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Внутренний IP</SectionTitle>
<Field label="IP-пул" required hint="Из какого пула выделяется /30-блок">
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="" disabled>Выбрать пул</option>
{grePools.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.cidr}) свободно {p.total - p.allocated} блоков</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Локальный IP" required hint="/ip address на этом конце">
<Input className="font-mono" placeholder="10.200.0.1/30" value={tForm.localInnerIp} onChange={(e) => setT("localInnerIp", e.target.value)} />
</Field>
<Field label="Удалённый IP" required hint="/ip address на другом конце">
<Input className="font-mono" placeholder="10.200.0.2/30" value={tForm.remoteInnerIp} onChange={(e) => setT("remoteInnerIp", e.target.value)} />
</Field>
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>IPsec</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Включить IPsec</p>
<p className="text-xs text-muted-foreground">RouterOS автоматически создаст peer, policy и proposal</p>
</div>
<Toggle checked={tForm.ipsecEnabled} onChange={(v) => setT("ipsecEnabled", v)} />
</div>
{tForm.ipsecEnabled && (
<div className="flex flex-col gap-4 pl-4 border-l-2 border-emerald-500/30">
<Field label="Пароль (PSK)" required hint="ipsec-secret — pre-shared key для автоматического IKE">
<div className="relative">
<Input type={tForm.ipsecShowSecret ? "text" : "password"} className="font-mono pr-9"
placeholder="Минимум 8 символов" value={tForm.ipsecSecret} onChange={(e) => setT("ipsecSecret", e.target.value)} />
<button type="button" onClick={() => setT("ipsecShowSecret", !tForm.ipsecShowSecret)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{tForm.ipsecShowSecret ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
</button>
</div>
</Field>
<Field label="IKE-версия">
<SegmentedControl value={tForm.ipsecIkeVersion} onChange={(v) => setT("ipsecIkeVersion", v)}
options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Шифрование">
<select value={tForm.ipsecEncAlg} onChange={(e) => setT("ipsecEncAlg", e.target.value as IpsecEncAlg)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(ENC_LABELS) as [IpsecEncAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label="Хеш-алгоритм">
<select value={tForm.ipsecAuthAlg} onChange={(e) => setT("ipsecAuthAlg", e.target.value as IpsecAuthAlg)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(AUTH_LABELS) as [IpsecAuthAlg, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
</div>
<Field label="DH-группа" hint="Группа Диффи-Хеллмана для обмена ключами">
<select value={tForm.ipsecDhGroup} onChange={(e) => setT("ipsecDhGroup", e.target.value as IpsecDhGroup)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
{(Object.entries(DH_LABELS) as [IpsecDhGroup, string][]).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Срок жизни SA" hint="Формат: 1d 00:00:00">
<Input className="font-mono" value={tForm.ipsecLifetime} onChange={(e) => setT("ipsecLifetime", e.target.value)} />
</Field>
<div className="flex items-center justify-between pt-6">
<span className="text-sm font-medium">PFS</span>
<Toggle checked={tForm.ipsecPfs} onChange={(v) => setT("ipsecPfs", v)} />
</div>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-4">
<button type="button" onClick={() => setT("showAdvanced", !tForm.showAdvanced)}
className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors">
{tForm.showAdvanced ? <ChevronDownIcon className="size-3.5" /> : <ChevronRightIcon className="size-3.5" />}
Дополнительно
<div className="flex-1 h-px bg-border" />
</button>
{tForm.showAdvanced && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-3">
<Field label="MTU" hint="По умолч. 1476">
<Input type="number" className="font-mono" value={tForm.mtu} onChange={(e) => setT("mtu", Number(e.target.value))} />
</Field>
<Field label="Keepalive, с" hint="0 = откл.">
<Input type="number" className="font-mono" value={tForm.keepaliveInterval} onChange={(e) => setT("keepaliveInterval", Number(e.target.value))} />
</Field>
<Field label="Попытки">
<Input type="number" className="font-mono" value={tForm.keepaliveRetries} onChange={(e) => setT("keepaliveRetries", Number(e.target.value))} />
</Field>
</div>
<Field label="DSCP">
<select value={tForm.dscp} onChange={(e) => setT("dscp", e.target.value)}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
<option value="inherit">inherit</option>
{Array.from({ length: 64 }, (_, i) => <option key={i} value={String(i)}>{i}</option>)}
</select>
</Field>
{[
{ key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" },
{ key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" },
].map(({ key, label, desc }) => (
<div key={key} className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle checked={tForm[key] as boolean} onChange={(v) => setT(key, v)} />
</div>
))}
</div>
)}
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */}
<Sheet open={poolOpen} onOpenChange={setPoolOpen}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Новый IP-пул</SheetTitle>
<SheetDescription>Пул адресов для назначения внутренних IP GRE-туннелям</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Параметры пула</SectionTitle>
<Field label="Имя пула" required hint="Например pool-gre-office или pool-gre-dc2">
<Input className="font-mono" placeholder="pool-gre-core" value={pForm.name}
onChange={(e) => setPForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Диапазон CIDR" required hint="Блок, из которого будут нарезаться /30 на каждый туннель">
<Input className="font-mono" placeholder="10.200.0.0/24" value={pForm.cidr}
onChange={(e) => setPForm((f) => ({ ...f, cidr: e.target.value }))} />
</Field>
<Field label="Назначение / Комментарий">
<Input placeholder="Ядровые межузловые туннели" value={pForm.comment}
onChange={(e) => setPForm((f) => ({ ...f, comment: e.target.value }))} />
</Field>
{pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => {
const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0")
const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0
return blocks > 0 ? (
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
<p className="text-muted-foreground">
Доступно <span className="font-semibold text-foreground font-mono">{blocks}</span> блоков /30
{" "}= до <span className="font-semibold text-foreground font-mono">{blocks}</span> туннелей
</p>
</div>
) : null
})()}
</div>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
<Button className="flex-1" onClick={() => setPoolOpen(false)}>Создать пул</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import { PageHeader } from "@/components/page-header"
import { DataTable } from "@/components/data-table"
import { ipRanges } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { UploadIcon, DownloadIcon, PlusIcon, FilterIcon } from "lucide-react"
export default function IpRangesPage() {
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Данные" }, { label: "IP-диапазоны" }]}
actions={
<>
<Button variant="outline" size="sm"><UploadIcon className="size-4" />Импорт</Button>
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
<Button size="sm"><PlusIcon className="size-4" />Добавить диапазон</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">IP-диапазоны</h1>
<p className="text-sm text-muted-foreground mt-1">
CIDR-блоки, отправляемые в address-list MikroTik по всем настроенным фильтрам
</p>
</div>
<DataTable
data={ipRanges}
searchPlaceholder="Поиск по CIDR, ASN…"
searchKeys={["cidr", "asn", "country", "filter"]}
columns={[
{
key: "cidr",
label: "CIDR",
render: (d) => <span className="font-mono font-medium">{d.cidr}</span>,
},
{
key: "asn",
label: "ASN",
render: (d) => <span className="font-mono text-xs text-muted-foreground">{d.asn}</span>,
},
{
key: "country",
label: "Страна",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.country}</span>,
},
{
key: "purpose",
label: "Назначение",
render: (d) => <span className="text-xs border border-border rounded px-2 py-0.5">{d.purpose}</span>,
},
{
key: "filter",
label: "Фильтр",
render: (d) => (
<span className="inline-flex items-center gap-1 text-xs bg-muted rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />{d.filter}
</span>
),
},
{
key: "updated",
label: "Обновлён",
render: (d) => <span className="text-xs text-muted-foreground">{d.updated}</span>,
},
{
key: "enabled",
label: "Статус",
render: (d) => (
<span className={`text-xs font-medium ${d.enabled ? "text-emerald-600" : "text-muted-foreground"}`}>
{d.enabled ? "Активен" : "Отключён"}
</span>
),
},
]}
/>
</div>
</div>
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { AppSidebar } from "@/components/app-sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { DataSourceProvider } from "@/lib/data-source"
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<DataSourceProvider>
<SidebarProvider>
<AppSidebar />
<SidebarInset className="h-svh overflow-hidden">{children}</SidebarInset>
<CommandPalette />
</SidebarProvider>
</DataSourceProvider>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+802
View File
@@ -0,0 +1,802 @@
"use client"
import { useEffect, useRef, useState, useMemo, useCallback } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { servers, greTunnels } from "@/lib/data"
import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
ZapIcon, ClockIcon, CheckIcon, TerminalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
type RunStatus = "running" | "done" | "error"
type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
interface OutputLine { text: string; kind: "normal" | "ok" | "err" | "dim" | "header" | "cmd" }
interface DiagTest {
id: string
tool: DiagTool
status: RunStatus
srcServerId: string
srcServerName: string
target: string
command: string
startedAt: number
lines: OutputLine[]
totalLines: number // final line count — reveals progressively
}
type SchedType = "ping" | "bandwidth" | "both"
interface SchedRule {
id: string; srcId: string; tunnelId: string; type: SchedType
intervalMin: number; enabled: boolean
lastRun: string | null; nextRunMin: number | null
}
// ─── tool metadata ────────────────────────────────────────────────────────────
const TOOL_META: Record<DiagTool, {
label: string
ros: string // RouterOS tool path
Icon: React.FC<{ className?: string }>
color: string
description: string
}> = {
ping: { label: "Ping", ros: "/tool ping", Icon: ActivityIcon, color: "text-sky-500", description: "Проверка связи и RTT" },
traceroute: { label: "Traceroute", ros: "/tool traceroute", Icon: RouteIcon, color: "text-violet-500", description: "Трассировка маршрута" },
bandwidth: { label: "BW-тест", ros: "/tool bandwidth-test", Icon: ZapIcon, color: "text-emerald-500", description: "Пропускная способность" },
dns: { label: "DNS", ros: "/resolve", Icon: SearchIcon, color: "text-amber-500", description: "Разрешение DNS-имён" },
route: { label: "Маршрут", ros: "/ip route lookup", Icon: NetworkIcon, color: "text-blue-500", description: "Поиск активного маршрута" },
mtu: { label: "MTU-тест", ros: "/tool ping", Icon: RulerIcon, color: "text-orange-500", description: "Определение MTU пути" },
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function jitter(base: number, pct = 0.15) {
return Math.max(1, Math.round(base + (Math.random() - 0.5) * base * pct * 2))
}
function seededRng(seed: string) {
let h = 0
for (let i = 0; i < seed.length; i++) h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0
h = Math.abs(h)
return (n = 233280) => { h = (h * 9301 + 49297) % n; return h / n }
}
function baseRtt(srcId: string, _target: string): number {
const map: Record<string, number> = { srv1: 13, srv2: 11, srv3: 5, srv4: 8, srv5: 80, srv6: 8, srv7: 14 }
return map[srcId] ?? 20
}
function randomIp(rng: () => number, base = "10.") {
return `${base}${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}.${Math.floor(rng() * 255)}`
}
// ─── output generators ────────────────────────────────────────────────────────
function genPingOutput(target: string, srcId: string, count: number, size: number, _ttl: number): OutputLine[] {
const rng = seededRng(target + srcId)
const base = baseRtt(srcId, target)
const rtts: (number | null)[] = Array.from({ length: count }, () =>
rng() < 0.04 ? null : jitter(base, 0.2),
)
const lines: OutputLine[] = [
{ text: ` SEQ HOST SIZE TTL TIME STATUS`, kind: "header" },
]
rtts.forEach((rtt, i) => {
lines.push({
text: ` ${String(i).padStart(3)} ${target.padEnd(40)} ${String(size).padStart(4)} ${String(rtt ? 118 : 0).padStart(3)} ${rtt ? `${rtt}ms`.padStart(6) : " "} ${rtt ? "" : "timeout"}`,
kind: rtt ? "normal" : "err",
})
})
const recv = rtts.filter(r => r !== null) as number[]
const loss = Math.round((count - recv.length) / count * 100)
lines.push({ text: "", kind: "dim" })
lines.push({
text: ` sent=${count} received=${recv.length} packet-loss=${loss}% min-rtt=${Math.min(...recv)}ms avg-rtt=${Math.round(recv.reduce((a, b) => a + b, 0) / recv.length)}ms max-rtt=${Math.max(...recv)}ms`,
kind: loss === 0 ? "ok" : "err",
})
return lines
}
function genTraceOutput(target: string, srcId: string, proto: TraceProto, maxHops: number): OutputLine[] {
const rng = seededRng(target + srcId + proto)
const base = baseRtt(srcId, target)
const hopCount = Math.min(maxHops, 4 + Math.floor(rng() * 3))
const lines: OutputLine[] = [
{ text: ` # ADDRESS LOSS SENT LAST AVG BEST WORST`, kind: "header" },
]
for (let i = 1; i <= hopCount; i++) {
const addr = i === 1 ? "10.200.0.2" : i === hopCount ? target : randomIp(rng, "95.213.")
const frac = i / hopCount
const rtt1 = Math.round(base * frac * jitter(1, 0.1))
const rtt2 = Math.round(base * frac * jitter(1, 0.08))
const rtt3 = Math.round(base * frac * jitter(1, 0.12))
const avg = Math.round((rtt1 + rtt2 + rtt3) / 3)
lines.push({
text: ` ${String(i).padStart(2)} ${addr.padEnd(40)} 0% 3 ${`${rtt1}ms`.padStart(6)} ${`${avg}ms`.padStart(6)} ${`${Math.min(rtt1,rtt2,rtt3)}ms`.padStart(6)} ${`${Math.max(rtt1,rtt2,rtt3)}ms`.padStart(6)}`,
kind: i === hopCount ? "ok" : "normal",
})
}
return lines
}
function genDnsOutput(target: string, srcId: string, type: DnsType): OutputLine[] {
const rng = seededRng(target + srcId + type)
const ttl = 300 - Math.floor(rng() * 200)
const time = 5 + Math.floor(rng() * 20)
const lines: OutputLine[] = [
{ text: `;; QUESTION SECTION:`, kind: "dim" },
{ text: `;; ${target}. IN ${type}`, kind: "dim" },
{ text: "", kind: "dim" },
{ text: `;; ANSWER SECTION:`, kind: "header" },
]
const count = type === "A" ? 2 + Math.floor(rng() * 2) : 1
for (let i = 0; i < count; i++) {
let value = ""
if (type === "A") value = `142.250.${74 + Math.floor(rng() * 10)}.${Math.floor(rng() * 200)}`
if (type === "AAAA") value = `2001:db8::${Math.floor(rng() * 0xffff).toString(16)}`
if (type === "MX") value = `10 mail.${target}.`
if (type === "NS") value = `ns${i + 1}.${target}.`
if (type === "TXT") value = `"v=spf1 include:${target} ~all"`
if (type === "CNAME") value = `${target}.cdn.example.com.`
if (type === "PTR") value = `${target}.in-addr.arpa.`
lines.push({ text: `${target.padEnd(24)} ${String(ttl).padStart(5)} IN ${type.padEnd(5)} ${value}`, kind: "ok" })
}
lines.push({ text: "", kind: "dim" })
lines.push({ text: `;; Query time: ${time} msec`, kind: "dim" })
lines.push({ text: `;; SERVER: 1.1.1.1`, kind: "dim" })
return lines
}
function genRouteOutput(target: string, srcId: string): OutputLine[] {
const rng = seededRng(target + srcId)
const gw = `10.200.0.${2 + Math.floor(rng() * 14)}`
const iface = ["gre-msk-fra", "gre-msk-ams", "gre-msk-spb"][Math.floor(rng() * 3)]
const prefix = target.split(".").slice(0, 3).join(".") + ".0/24"
const asn = [15169, 32934, 20940][Math.floor(rng() * 3)]
const comm = `65001:${100 + Math.floor(rng() * 4) * 100}`
return [
{ text: `Flags: X - disabled, A - active, B - blackhole, U - unreachable`, kind: "dim" },
{ text: "", kind: "dim" },
{ text: ` # DST-ADDRESS PREF-SRC GATEWAY DISTANCE SCOPE TARGET-SCOPE`, kind: "header" },
{ text: ` 0 A ${prefix.padEnd(18)} ${gw.padEnd(15)} 200 30 10`, kind: "ok" },
{ text: ` routing-table: main`, kind: "dim" },
{ text: ` bgp-as-path: ${asn}`, kind: "normal" },
{ text: ` bgp-communities: ${comm} ${asn}:5003`, kind: "normal" },
{ text: ` bgp-local-pref: 100`, kind: "dim" },
{ text: ` bgp-med: 0`, kind: "dim" },
{ text: ` bgp-origin: igp`, kind: "dim" },
{ text: ` bgp-nexthop: ${gw}`, kind: "dim" },
{ text: ` bgp-ext-communities: (nothing)`, kind: "dim" },
{ text: ` interface: ${iface}`, kind: "normal" },
{ text: ` gateway: ${gw}`, kind: "normal" },
{ text: "", kind: "dim" },
{ text: `1 route found`, kind: "ok" },
]
}
function genMtuOutput(target: string, srcId: string): OutputLine[] {
const base = baseRtt(srcId, target)
const mtu = [1476, 1472, 1468, 1400][Math.floor(Math.random() * 2)] // common GRE MTUs
const sizes = [1500, 1492, mtu + 8, mtu + 4, mtu, mtu - 4]
const lines: OutputLine[] = [
{ text: `MTU path discovery → ${target} (do-not-fragment ping)`, kind: "header" },
{ text: "", kind: "dim" },
{ text: ` SIZE RESULT RTT`, kind: "dim" },
]
sizes.forEach(sz => {
const pass = sz <= mtu
const rtt = pass ? jitter(base, 0.1) : null
lines.push({
text: ` ${String(sz).padStart(4)} ${pass ? "✓ PASS" : "✗ FAIL (frag needed)"} ${pass ? `${rtt}ms` : "—"}`,
kind: pass ? "ok" : "err",
})
})
lines.push({ text: "", kind: "dim" })
lines.push({ text: `MTU discovered: ${mtu} bytes`, kind: "ok" })
const overhead = 1500 - mtu
lines.push({ text: `Overhead: ${overhead} bytes (GRE ${overhead >= 24 ? "+IPsec" : "no IPsec"})`, kind: "dim" })
return lines
}
function genBwOutput(target: string, srcId: string, proto: string, duration: number): OutputLine[] {
const base = [410, 580, 220, 680][Math.floor(Math.random() * 4)]
seededRng(target + srcId)
const lines: OutputLine[] = [
{ text: ` status: running`, kind: "dim" },
{ text: ` direction: both`, kind: "dim" },
{ text: ` protocol: ${proto.toUpperCase()}`, kind: "dim" },
{ text: ` duration: ${duration}s`, kind: "dim" },
{ text: "", kind: "dim" },
]
for (let t = 2; t <= duration; t += 2) {
const tx = jitter(base * 0.98, 0.08)
const rx = jitter(base * 0.95, 0.09)
lines.push({
text: ` [${String(t).padStart(2)}s] tx-current: ${tx}Mbps rx-current: ${rx}Mbps`,
kind: "normal",
})
}
const txAvg = jitter(base * 0.97, 0.04)
const rxAvg = jitter(base * 0.94, 0.04)
lines.push({ text: "", kind: "dim" })
lines.push({ text: ` status: done`, kind: "ok" })
lines.push({ text: ` tx-total-average: ${txAvg}Mbps`, kind: "ok" })
lines.push({ text: ` rx-total-average: ${rxAvg}Mbps`, kind: "ok" })
return lines
}
// ─── command builder ──────────────────────────────────────────────────────────
function buildCommand(
tool: DiagTool, src: string, target: string,
opts: { pingCount?: number; pingSize?: number; pingTtl?: number; traceProto?: TraceProto; traceMaxHops?: number; dnsType?: DnsType; mtuStart?: number; bwProto?: string; bwDuration?: number; bwTarget?: string },
): string {
const host = servers.find(s => s.id === src)?.host ?? "?"
switch (tool) {
case "ping":
return `/tool ping address=${target} count=${opts.pingCount ?? 5} size=${opts.pingSize ?? 64} ttl=${opts.pingTtl ?? 64} src-address=${host}`
case "traceroute":
return `/tool traceroute address=${target} max-hops=${opts.traceMaxHops ?? 30} protocol=${opts.traceProto ?? "icmp"} src-address=${host}`
case "bandwidth":
return `/tool bandwidth-test address=${opts.bwTarget ?? target} duration=${opts.bwDuration ?? 10}s protocol=${opts.bwProto ?? "tcp"} direction=both`
case "dns":
return `/resolve ${target} type=${opts.dnsType ?? "A"} server=1.1.1.1`
case "route":
return `/ip route lookup ip=${target}`
case "mtu":
return `/tool ping address=${target} do-not-fragment count=1 size=${opts.mtuStart ?? 1500} src-address=${host}`
}
}
// ─── small UI components ──────────────────────────────────────────────────────
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 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 Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button 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 OptionLabel({ children }: { children: React.ReactNode }) {
return <p className="text-[11px] font-medium text-muted-foreground mb-1">{children}</p>
}
function SegBtn<T extends string | number>({ value, current, onClick, children }: {
value: T; current: T; onClick: (v: T) => void; children: React.ReactNode
}) {
return (
<button onClick={() => onClick(value)}
className={cn(
"px-2.5 py-1 text-xs font-mono border-r last:border-r-0 border-input transition-colors",
value === current ? "bg-muted text-foreground font-semibold" : "text-muted-foreground hover:bg-muted/50",
)}>
{children}
</button>
)
}
// ─── terminal output ──────────────────────────────────────────────────────────
function TerminalOutput({ test }: { test: DiagTest }) {
const ref = useRef<HTMLDivElement>(null)
const visible = test.lines.slice(0, test.totalLines)
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
}, [visible.length])
return (
<div ref={ref}
className="h-72 overflow-y-auto rounded-lg bg-zinc-950 dark:bg-zinc-900 border border-zinc-800 px-4 py-3 font-mono text-xs leading-relaxed">
{/* command line */}
<div className="mb-2 flex items-center gap-2">
<span className="text-zinc-500">[{test.srcServerName}]</span>
<span className="text-emerald-400">$</span>
<span className="text-zinc-300">{test.command}</span>
{test.status === "running" && (
<span className="inline-block size-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />
)}
</div>
{/* output */}
{visible.map((line, i) => (
<div key={i} className={cn(
"whitespace-pre leading-5",
line.kind === "ok" && "text-emerald-400",
line.kind === "err" && "text-red-400",
line.kind === "dim" && "text-zinc-500",
line.kind === "header" && "text-zinc-400 font-semibold",
line.kind === "cmd" && "text-amber-400",
line.kind === "normal" && "text-zinc-300",
)}>
{line.text || " "}
</div>
))}
{test.status === "running" && visible.length < test.lines.length && (
<div className="text-zinc-600 animate-pulse"></div>
)}
</div>
)
}
// ─── schedule tab ─────────────────────────────────────────────────────────────
const INIT_RULES: SchedRule[] = [
{ id: "r1", srcId: "srv1", tunnelId: "gre1", type: "both", intervalMin: 15, enabled: true, lastRun: "3 мин назад", nextRunMin: 12 },
{ id: "r2", srcId: "srv1", tunnelId: "gre2", type: "ping", intervalMin: 5, enabled: true, lastRun: "1 мин назад", nextRunMin: 4 },
{ id: "r3", srcId: "srv7", tunnelId: "gre5", type: "bandwidth", intervalMin: 60, enabled: false, lastRun: "2ч назад", nextRunMin: null },
]
function ScheduleTab({ rules, setRules }: {
rules: SchedRule[]; setRules: React.Dispatch<React.SetStateAction<SchedRule[]>>
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState("srv1")
const [addTun, setAddTun] = useState("gre1")
const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => greTunnels.filter(t => t.serverId === addSrc), [addSrc])
const typeLabel: Record<SchedType, string> = { ping: "Ping", bandwidth: "BW-тест", both: "Ping + BW" }
return (
<div className="flex flex-col gap-3">
<Card className="overflow-hidden">
{rules.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 text-muted-foreground">
<ClockIcon className="size-7 opacity-20" />
<p className="text-sm">Нет правил расписания</p>
</div>
) : (
<>
<div className="grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2 bg-muted/30 border-b text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
<span />
<span>Туннель</span>
<span>Сервер</span>
<span>Тип</span>
<span>Интервал</span>
<span>Последний / следующий</span>
<span />
</div>
<div className="divide-y divide-border/60">
{rules.map(rule => {
const src = servers.find(s => s.id === rule.srcId)
const tun = greTunnels.find(t => t.id === rule.tunnelId)
return (
<div key={rule.id} className={cn(
"grid grid-cols-[40px_1fr_140px_80px_100px_1fr_auto] gap-2 items-center px-4 py-2.5 hover:bg-muted/20 transition-colors",
!rule.enabled && "opacity-50",
)}>
<Toggle checked={rule.enabled}
onChange={v => setRules(p => p.map(r => r.id === rule.id ? { ...r, enabled: v } : r))} />
<code className="font-mono text-xs truncate">{tun?.name ?? rule.tunnelId}</code>
<span className="text-xs text-muted-foreground truncate">{src?.name ?? rule.srcId}</span>
<span className={cn("text-[10px] px-1.5 py-0.5 rounded border font-medium w-fit",
rule.type === "ping" ? "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20"
: rule.type === "bandwidth" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20",
)}>{typeLabel[rule.type]}</span>
<span className="text-xs text-muted-foreground">каждые {rule.intervalMin} мин</span>
<div className="text-xs text-muted-foreground flex items-center gap-2 min-w-0">
{rule.lastRun && <span className="truncate">{rule.lastRun}</span>}
{rule.nextRunMin != null && rule.enabled && (
<span className="text-sky-600 dark:text-sky-400 shrink-0">· через {rule.nextRunMin} мин</span>
)}
</div>
<button onClick={() => setRules(p => p.filter(r => r.id !== rule.id))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
)
})}
</div>
</>
)}
</Card>
{showAdd ? (
<Card className="overflow-hidden">
<div className="px-4 py-3 border-b flex items-center gap-2 text-sm font-medium">
<PlusIcon className="size-4 text-muted-foreground" />Новое правило
</div>
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
<div><OptionLabel>Сервер</OptionLabel>
<NativeSelect value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
{servers.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</NativeSelect>
</div>
<div><OptionLabel>GRE-туннель</OptionLabel>
<NativeSelect value={addTun} onChange={setAddTun} className="min-w-[150px]">
{addTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect>
</div>
<div><OptionLabel>Тип</OptionLabel>
<NativeSelect value={addType} onChange={v => setAddType(v as SchedType)}>
<option value="ping">Ping</option>
<option value="bandwidth">BW-тест</option>
<option value="both">Ping + BW</option>
</NativeSelect>
</div>
<div><OptionLabel>Интервал (мин)</OptionLabel>
<Input type="number" min={1} max={1440} value={addMin}
onChange={e => setAddMin(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20 font-mono text-sm" />
</div>
<Button size="sm" disabled={!addTunnels.length}
onClick={() => {
setRules(p => [...p, { id: `r${Date.now()}`, srcId: addSrc, tunnelId: addTun, type: addType, intervalMin: addMin, enabled: true, lastRun: null, nextRunMin: addMin }])
setShowAdd(false)
}}>
<CheckIcon className="size-4" />Добавить
</Button>
<Button variant="outline" size="sm" onClick={() => setShowAdd(false)}>Отмена</Button>
</div>
</Card>
) : (
<Button variant="outline" size="sm" className="w-fit" onClick={() => setShowAdd(true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
)}
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() {
// ── tool config ──
const [tool, setTool] = useState<DiagTool>("ping")
const [srcId, setSrcId] = useState(servers[0]?.id ?? "srv1")
const [target, setTarget] = useState("8.8.8.8")
const [pingCount, setPingCount] = useState(5)
const [pingSize, setPingSize] = useState(64)
const [pingTtl] = useState(64)
const [traceProto, setTraceProto] = useState<TraceProto>("icmp")
const [traceHops] = useState(30)
const [dnsType, setDnsType] = useState<DnsType>("A")
const [bwTunId, setBwTunId] = useState(greTunnels[0]?.id ?? "gre1")
const [bwProto, setBwProto] = useState<"tcp" | "udp">("tcp")
const [bwDuration, setBwDuration] = useState(10)
// ── run state ──
const [tests, setTests] = useState<DiagTest[]>([])
const [tab, setTab] = useState<"history" | "schedule">("history")
const [rules, setRules] = useState<SchedRule[]>(INIT_RULES)
const nextId = useRef(1)
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null)
const bwTunnels = useMemo(() => greTunnels.filter(t => t.serverId === srcId), [srcId])
const srcServer = useMemo(() => servers.find(s => s.id === srcId), [srcId])
// current command preview
const cmdPreview = useMemo(() => buildCommand(tool, srcId, target, {
pingCount, pingSize, pingTtl, traceProto, traceMaxHops: traceHops, dnsType,
bwTarget: greTunnels.find(t => t.id === bwTunId)?.remoteAddress, bwProto, bwDuration,
}), [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration])
// ── progressive reveal tick ──
useEffect(() => {
tickRef.current = setInterval(() => {
setTests(prev => {
const hasRunning = prev.some(t => t.status === "running")
if (!hasRunning) return prev
return prev.map(t => {
if (t.status !== "running") return t
const speed = t.tool === "dns" || t.tool === "route" ? t.lines.length : 1
const nextTotal = Math.min(t.totalLines + speed, t.lines.length)
const done = nextTotal >= t.lines.length
return { ...t, totalLines: nextTotal, status: done ? "done" : "running" }
})
})
}, 400)
return () => { if (tickRef.current) clearInterval(tickRef.current) }
}, [])
// ── run test ──
const runTest = useCallback(() => {
const srv = servers.find(s => s.id === srcId)
if (!srv) return
const id = String(nextId.current++)
let lines: OutputLine[] = []
const bwTun = greTunnels.find(t => t.id === bwTunId)
switch (tool) {
case "ping": lines = genPingOutput(target, srcId, pingCount, pingSize, pingTtl); break
case "traceroute":lines = genTraceOutput(target, srcId, traceProto, traceHops); break
case "dns": lines = genDnsOutput(target, srcId, dnsType); break
case "route": lines = genRouteOutput(target, srcId); break
case "mtu": lines = genMtuOutput(target, srcId); break
case "bandwidth": lines = genBwOutput(bwTun?.remoteAddress ?? target, srcId, bwProto, bwDuration); break
}
const test: DiagTest = {
id, tool, status: "running",
srcServerId: srcId, srcServerName: srv.name,
target: tool === "bandwidth" ? (bwTun?.name ?? target) : target,
command: cmdPreview,
startedAt: Date.now(),
lines,
totalLines: 0,
}
setTests(p => [test, ...p.slice(0, 9)]) // keep last 10
setTab("history")
}, [tool, srcId, target, pingCount, pingSize, pingTtl, traceProto, traceHops, dnsType, bwTunId, bwProto, bwDuration, cmdPreview])
const stopTest = (id: string) => setTests(p => p.map(t => t.id === id ? { ...t, status: "done", totalLines: t.lines.length } : t))
const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
const running = tests.filter(t => t.status === "running")
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Диагностика" }]}
actions={
running.length > 0
? <Button variant="outline" size="sm" onClick={() => setTests(p => p.map(t => ({ ...t, status: "done" as const, totalLines: t.lines.length })))}>
<SquareIcon className="size-4" />Остановить все
</Button>
: undefined
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
{/* ── tool selector + config ── */}
<Card>
<CardContent className="pt-4 pb-4 px-4 flex flex-col gap-4">
{/* tool chips */}
<div className="flex gap-2 flex-wrap">
{(Object.entries(TOOL_META) as [DiagTool, typeof TOOL_META[DiagTool]][]).map(([t, m]) => {
const active = tool === t
return (
<button key={t} onClick={() => setTool(t)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors",
active
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
<m.Icon className={cn("size-3.5", active ? "" : m.color)} />
{m.label}
</button>
)
})}
</div>
{/* main config row */}
<div className="flex flex-wrap items-end gap-3">
{/* source server */}
<div>
<OptionLabel>Источник</OptionLabel>
<NativeSelect value={srcId} onChange={setSrcId} className="min-w-[175px]">
{servers.filter(s => s.enabled).map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</NativeSelect>
</div>
{/* target — all tools except bandwidth */}
{tool !== "bandwidth" && (
<div className="flex-1 min-w-[140px]">
<OptionLabel>
{tool === "dns" ? "Домен" : tool === "route" ? "Destination IP" : "Цель (IP или домен)"}
</OptionLabel>
<Input value={target} onChange={e => setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()}
/>
</div>
)}
{/* bandwidth: select GRE tunnel */}
{tool === "bandwidth" && (
<div>
<OptionLabel>GRE-туннель (цель)</OptionLabel>
<NativeSelect value={bwTunId} onChange={setBwTunId} className="min-w-[180px]">
{bwTunnels.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</NativeSelect>
</div>
)}
{/* inline quick options */}
{tool === "ping" && (
<>
<div>
<OptionLabel>Кол-во</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[5, 10, 25, 100].map(n => <SegBtn key={n} value={n} current={pingCount} onClick={setPingCount}>{n}</SegBtn>)}
</div>
</div>
<div>
<OptionLabel>Размер (байт)</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[64, 128, 512, 1472].map(n => <SegBtn key={n} value={n} current={pingSize} onClick={setPingSize}>{n}</SegBtn>)}
</div>
</div>
</>
)}
{tool === "traceroute" && (
<div>
<OptionLabel>Протокол</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["icmp", "udp", "tcp"] as TraceProto[]).map(p => <SegBtn key={p} value={p} current={traceProto} onClick={setTraceProto}>{p.toUpperCase()}</SegBtn>)}
</div>
</div>
)}
{tool === "dns" && (
<div>
<OptionLabel>Тип записи</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["A", "AAAA", "MX", "NS", "TXT", "PTR"] as DnsType[]).map(t => <SegBtn key={t} value={t} current={dnsType} onClick={setDnsType}>{t}</SegBtn>)}
</div>
</div>
)}
{tool === "bandwidth" && (
<>
<div>
<OptionLabel>Протокол</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{(["tcp", "udp"] as const).map(p => <SegBtn key={p} value={p} current={bwProto} onClick={setBwProto}>{p.toUpperCase()}</SegBtn>)}
</div>
</div>
<div>
<OptionLabel>Длительность</OptionLabel>
<div className="flex rounded-lg border border-input overflow-hidden h-8">
{[5, 10, 30].map(n => <SegBtn key={n} value={n} current={bwDuration} onClick={setBwDuration}>{n}с</SegBtn>)}
</div>
</div>
</>
)}
<Button onClick={runTest} className="h-8 shrink-0 gap-1.5 self-end">
<PlayIcon className="size-3.5" />Запустить
</Button>
</div>
{/* RouterOS command preview */}
<div className="flex items-center gap-2 rounded-lg bg-zinc-950 dark:bg-zinc-900 px-3 py-2 border border-zinc-800">
<TerminalIcon className="size-3 text-zinc-500 shrink-0" />
<span className="text-zinc-500 text-xs font-mono shrink-0">[{srcServer?.name}]</span>
<span className="text-emerald-400 text-xs font-mono shrink-0">$</span>
<span className="text-zinc-300 text-xs font-mono truncate">{cmdPreview}</span>
<button onClick={() => navigator.clipboard.writeText(cmdPreview)}
className="shrink-0 text-zinc-600 hover:text-zinc-300 transition-colors ml-auto">
<CopyIcon className="size-3" />
</button>
</div>
</CardContent>
</Card>
{/* ── tabs ── */}
<div>
<div className="flex items-center gap-0 border-b">
{([
["history", "История тестов"],
["schedule", "Расписание"],
] as const).map(([t, label]) => (
<button key={t} onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-sm font-medium -mb-px border-b-2 transition-colors",
tab === t
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}>
{label}
{t === "history" && tests.length > 0 && (
<span className="ml-1.5 text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
{tests.length}
</span>
)}
</button>
))}
</div>
<div className="mt-4">
{/* history */}
{tab === "history" && (
tests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground gap-2">
<TerminalIcon className="size-8 opacity-20" />
<p className="text-sm">Запустите тест результаты появятся здесь</p>
</div>
) : (
<div className="flex flex-col gap-4">
{tests.map(test => {
const { Icon, color, label } = TOOL_META[test.tool]
return (
<Card key={test.id} className="overflow-hidden">
{/* header */}
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b bg-muted/20">
<div className="flex items-center gap-2 min-w-0">
<Icon className={cn("size-4 shrink-0", color)} />
<span className="text-sm font-medium">{label}</span>
<span className="text-xs font-mono text-muted-foreground truncate">{test.target}</span>
<span className="text-[10px] text-muted-foreground"> {test.srcServerName}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{test.status === "running" && (
<>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
запущен
</span>
<Button size="sm" variant="outline" className="h-6 px-2 text-xs"
onClick={() => stopTest(test.id)}>
<SquareIcon className="size-3" />Стоп
</Button>
</>
)}
{test.status === "done" && (
<span className="text-[11px] text-muted-foreground">
{((test.lines.length * 0.4)).toFixed(1)}с
</span>
)}
<button onClick={() => navigator.clipboard.writeText(test.lines.map(l => l.text).join("\n"))}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-foreground hover:bg-muted transition-colors">
<CopyIcon className="size-3.5" />
</button>
<button onClick={() => clearTest(test.id)}
className="size-6 flex items-center justify-center rounded text-muted-foreground/40 hover:text-red-500 hover:bg-red-500/10 transition-colors">
<Trash2Icon className="size-3.5" />
</button>
</div>
</div>
{/* terminal output */}
<div className="p-3">
<TerminalOutput test={test} />
</div>
</Card>
)
})}
</div>
)
)}
{/* schedule */}
{tab === "schedule" && <ScheduleTab rules={rules} setRules={setRules} />}
</div>
</div>
</div>
</div>
</div>
)
}
+879
View File
@@ -0,0 +1,879 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { cn } from "@/lib/utils"
import { servers as mockServers, type Server } from "@/lib/data"
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, ChevronDownIcon, ChevronRightIcon, AlertCircleIcon, CheckIcon } from "lucide-react"
interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
enabled: boolean
status: "online" | "offline" | null
latency: number | null
}
interface GatewayOption {
id: string
name: string
ip: string
status: "up" | "down"
}
const INFER_COUNTRIES = [
{ code: "RU", keys: ["MSK", "SPB", "RTK", "MTS", "VPSVILLE", "IHOR"] },
{ code: "SE", keys: ["SWE", "STO"] },
{ code: "FI", keys: ["HEL", "FIN"] },
{ code: "DE", keys: ["FRA", "GER", "DE"] },
{ code: "NL", keys: ["AMS", "NLD", "NL"] },
{ code: "SG", keys: ["SGP", "SIN", "SG"] },
{ code: "TR", keys: ["TUR", "TR"] },
{ code: "US", keys: ["USA", "US", "NYC", "LAX"] },
]
function inferCountry(name: string): string | null {
const upper = name.toUpperCase()
for (const c of INFER_COUNTRIES) {
if (c.keys.some(k => upper.includes(k))) return c.code
}
return null
}
const COUNTRY_OPTIONS = [
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
{ code: "FI", label: "Финляндия" }, { code: "SE", label: "Швеция" },
{ code: "FR", label: "Франция" }, { code: "GB", label: "Великобритания" },
{ code: "PL", label: "Польша" }, { code: "US", label: "США" },
{ code: "UA", label: "Украина" }, { code: "TR", label: "Турция" },
{ code: "JP", label: "Япония" }, { code: "HK", label: "Гонконг" },
{ code: "KZ", label: "Казахстан" }, { code: "BY", label: "Беларусь" },
{ code: "LT", label: "Литва" }, { code: "LV", label: "Латвия" },
{ code: "EE", label: "Эстония" }, { code: "CZ", label: "Чехия" },
{ code: "AT", label: "Австрия" }, { code: "CH", label: "Швейцария" },
{ code: "NO", label: "Норвегия" },
]
interface RecursiveRouteRow {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
comment: string
disabled: boolean
country: string
}
interface RouteGroup {
key: string
dstAddress: string
routingTable: string
comment: string
endpoints: RecursiveRouteRow[]
}
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
...init,
headers: { ...headers, ...(init?.headers ?? {}) },
})
if (!res.ok) {
let message = res.statusText
try {
const err = await res.json() as { error?: string }
message = err.error ?? message
} catch {
const text = await res.text().catch(() => "")
if (text) message = text
}
throw new Error(message)
}
return res.json() as Promise<T>
}
}
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: s.site,
country: s.country || "UN",
asn: s.asn,
type: s.type,
enabled: s.enabled,
status: (s.status ?? "offline") as Server["status"],
latency: s.latency != null ? Math.round(s.latency) : null,
sessions: 0,
}
}
interface RouteForm {
dstAddress: string
routingTable: string
comment: string
endpoints: RouteEndpointForm[]
}
interface RouteEndpointForm {
id: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
checkGateway: string
country: string
}
const newEndpoint = (): RouteEndpointForm => ({
id: `ep-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
gateway: "",
distance: 1,
scope: null,
targetScope: null,
checkGateway: "ping",
country: "",
})
const emptyForm = (): RouteForm => ({
dstAddress: "",
routingTable: "main",
comment: "",
endpoints: [newEndpoint()],
})
function Field({ label, hint, required, children }: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{label}{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
</div>
)
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{children}</span>
<div className="flex-1 h-px bg-border" />
</div>
)
}
function RouteGroupRows({
group, expanded, onToggle, onEdit, onDelete,
}: {
group: RouteGroup
expanded: boolean
onToggle: () => void
onEdit: () => void
onDelete: () => void
}) {
const [confirmDel, setConfirmDel] = useState(false)
const bestDistance = Math.min(...group.endpoints.map(ep => ep.distance))
const sorted = [...group.endpoints].sort((a, b) => a.distance - b.distance)
return (
<>
<tr
className={cn(
"hover:bg-muted/40 transition-colors cursor-pointer group",
expanded && "bg-muted/30",
)}
onClick={onToggle}
>
<td className="px-5 py-3">
<div className="flex items-start gap-2">
{expanded
? <ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
: <ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0">
<p className="font-medium truncate">{group.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{group.comment || "—"}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-0.5">
{sorted.map((ep, idx) => {
const code = ep.country || inferCountry(ep.gateway)
return (
<div key={ep.id} className="flex items-center gap-1.5 text-[11px] font-mono">
<span className={cn(
"size-1.5 rounded-full shrink-0",
idx === 0 ? "bg-emerald-500" : "bg-sky-500",
)} />
{code ? <Flag code={code} size={14} className="shrink-0" /> : <span className="text-[10px] text-muted-foreground w-3.5 text-center shrink-0">?</span>}
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">{ep.gateway}</span>
</div>
)
})}
</div>
</td>
<td className="px-4 py-3 text-xs tabular-nums">{group.endpoints.length}</td>
<td className="px-4 py-3 font-mono text-xs">d{bestDistance}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{group.routingTable || "main"}</td>
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" variant="ghost" className="size-7 p-0 text-muted-foreground hover:text-foreground" onClick={onEdit}><PencilIcon className="size-3.5" /></Button>
<Button size="sm" variant="ghost" className={cn("size-7 p-0 transition-colors", confirmDel ? "text-destructive bg-destructive/10 hover:bg-destructive/20" : "text-muted-foreground hover:text-destructive")} onClick={() => { if (!confirmDel) setConfirmDel(true); else onDelete() }} onBlur={() => setConfirmDel(false)}>
{confirmDel ? <AlertCircleIcon className="size-3.5" /> : <TrashIcon className="size-3.5" />}
</Button>
</div>
</td>
</tr>
{expanded && (
<tr className="bg-muted/20">
<td colSpan={6} className="px-8 py-5 border-b border-border/50">
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-xs">
<span className="text-muted-foreground">Route: <span className="font-mono text-foreground">{group.dstAddress}</span></span>
<span className="text-muted-foreground">Table: <span className="font-mono text-foreground">{group.routingTable || "main"}</span></span>
{group.comment && <span className="text-muted-foreground italic">{group.comment}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2.5">
{sorted.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-background px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{(ep.country || inferCountry(ep.gateway)) && (
<Flag code={ep.country || inferCountry(ep.gateway) || ""} size={16} />
)}
<span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide">Endpoint {idx + 1}</span>
</div>
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
</div>
</div>
))}
</div>
</div>
</td>
</tr>
)}
</>
)
}
function EndpointCountryField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [query, setQuery] = useState("")
const q = query.trim().toUpperCase()
const visible = q.length === 0
? COUNTRY_OPTIONS
: COUNTRY_OPTIONS.filter(c => c.code.startsWith(q) || c.label.toLowerCase().includes(query.trim().toLowerCase()))
return (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Страна</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v)
if (v.length === 2) {
const match = COUNTRY_OPTIONS.find(c => c.code === v)
if (match) onChange(match.code)
else onChange(v)
}
}}
className="font-mono pr-10 h-8 text-sm" />
{value && (
<span className="absolute right-2.5 top-1/2 -translate-y-1/2">
<Flag code={value} size={20} />
</span>
)}
</div>
{value && (
<span className="text-sm font-mono text-muted-foreground shrink-0">
{COUNTRY_OPTIONS.find(c => c.code === value)?.label ?? value}
</span>
)}
</div>
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5">
{visible.map(c => (
<button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }}
title={`${c.code} · ${c.label}`}
className={cn(
"flex flex-col items-center gap-1 px-1 py-2 rounded-lg border text-[10px] transition-all",
value === c.code
? "border-primary bg-primary/5 ring-1 ring-primary/30 font-semibold text-primary"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40 text-muted-foreground",
)}>
<Flag code={c.code} size={24} />
<span className="font-mono leading-none">{c.code}</span>
</button>
))}
</div>
</div>
)
}
function RouteSheet({
open, mode, initial, onSave, onClose, gateways,
}: {
open: boolean
mode: "create" | "edit"
initial: RouteForm
onSave: (v: RouteForm) => void
onClose: () => void
gateways: GatewayOption[]
}) {
const [form, setForm] = useState<RouteForm>(initial)
const [error, setError] = useState<string | null>(null)
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
function handleSave() {
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
setError(null)
onSave(form)
}
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-2">
<div>
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div>
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<Field label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</Field>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Конечные точки</SectionTitle>
<div className="flex flex-col gap-3">
{form.endpoints.map((ep, idx) => (
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Endpoint {idx + 1}
</span>
<button
type="button"
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={form.endpoints.length <= 1}
>
<TrashIcon className="size-3.5" />
</button>
</div>
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<Field label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
</Field>
<Field label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</Field>
<Field label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</Field>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
const country = inferCountry(g.name)
return (
<button
key={`${ep.id}-${g.id}`}
type="button"
onClick={() => setEp(ep.id, "gateway", value)}
className={cn(
"text-left rounded-lg border px-3 py-2.5 transition-all",
selected
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
)}
>
<div className="flex items-center gap-2">
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "up" ? "bg-emerald-500" : "bg-red-500")} />
{country && <Flag code={country} className="shrink-0" />}
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
{selected && <CheckIcon className="size-3.5 text-primary shrink-0" />}
</div>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
<span className="text-muted-foreground/30"></span>
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
{value}
</span>
</div>
</button>
)
})}
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
<PlusIcon className="size-3.5" />Добавить endpoint
</Button>
{gateways.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
Нет доступных шлюзов на выбранном роутере.
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle>
<Field label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></Field>
<Field label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</Field>
</div>
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
return (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
export default function RecursiveRoutesPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Server[]>(mockServers)
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
const [search, setSearch] = useState("")
const [sheetOpen, setSheetOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
const [sheetInitial, setSheetInitial] = useState<RouteForm>(emptyForm())
const [editingGroupKey, setEditingGroupKey] = useState<string | null>(null)
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
const [opError, setOpError] = useState<string | null>(null)
useEffect(() => {
if (!isLive) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setServers(mockServers)
setSelectedServerId(mockServers[0]?.id ?? "")
setRows([])
return
}
apiFetch<BackendServer[]>("/api/servers")
.then((data) => {
const mapped = data.map(mapBackendServer)
setServers(mapped)
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
})
.catch(() => {
setServers([])
setSelectedServerId("")
})
}, [isLive, apiFetch])
const loadRoutes = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("load")
try {
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
setRows(res.routes)
} catch (e) {
setRows([])
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
const loadGateways = useCallback(async () => {
if (!isLive || !selectedServerId) return
try {
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
setGatewayOptions(res.gateways)
} catch {
setGatewayOptions([])
}
}, [isLive, selectedServerId, apiFetch])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadRoutes()
}, [loadRoutes])
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadGateways()
}, [loadGateways])
const saveToDb = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("save")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
method: "PUT",
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
const syncFromRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("from")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
await loadRoutes()
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch, loadRoutes])
const syncToRouter = useCallback(async () => {
if (!isLive || !selectedServerId) return
setOpError(null)
setBusy("to")
try {
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
method: "POST",
body: JSON.stringify({ serverId: selectedServerId }),
})
} catch (e) {
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
} finally {
setBusy(null)
}
}, [isLive, selectedServerId, apiFetch])
function groupKeyOf(row: RecursiveRouteRow): string {
return row.dstAddress.trim().toLowerCase()
}
function openCreate() {
setSheetMode("create")
setEditingGroupKey(null)
setSheetInitial(emptyForm())
setSheetOpen(true)
}
function openEdit(group: RouteGroup) {
setSheetMode("edit")
setEditingGroupKey(group.key)
setSheetInitial({
dstAddress: group.dstAddress,
routingTable: group.routingTable,
comment: group.comment,
endpoints: group.endpoints.map(ep => ({
id: ep.id,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
checkGateway: ep.checkGateway,
country: ep.country || "",
})),
})
setSheetOpen(true)
}
function handleSaveSheet(v: RouteForm) {
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
id,
dstAddress: v.dstAddress,
gateway: ep.gateway,
distance: ep.distance,
scope: ep.scope,
targetScope: ep.targetScope,
routingTable: v.routingTable,
checkGateway: ep.checkGateway,
comment: v.comment,
disabled: false,
country: ep.country || inferCountry(ep.gateway) || "",
})
if (sheetMode === "create") {
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
setRows(prev => [...prev, ...expanded])
} else if (editingGroupKey) {
setRows(prev => {
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
return [...kept, ...expanded]
})
}
setSheetOpen(false)
}
const currentServer = servers.find(s => s.id === selectedServerId)
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
return rows.filter(r =>
r.dstAddress.toLowerCase().includes(q) ||
r.gateway.toLowerCase().includes(q) ||
r.routingTable.toLowerCase().includes(q) ||
r.comment.toLowerCase().includes(q),
)
}, [rows, search])
const groupedRoutes = useMemo(() => {
const map = new Map<string, RouteGroup>()
for (const r of filteredRows) {
const key = groupKeyOf(r)
const ex = map.get(key)
if (ex) {
ex.endpoints.push(r)
if (!ex.comment && r.comment) ex.comment = r.comment
}
else map.set(key, {
key,
dstAddress: r.dstAddress,
routingTable: r.routingTable,
comment: r.comment,
endpoints: [r],
})
}
return [...map.values()]
}, [filteredRows])
return (
<div className="flex h-full flex-col">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "Рекурсивные маршруты" }]}
actions={
<>
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
{busy === "from" ? "Синхронизация..." : "Router => DB"}
</Button>
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
{busy === "to" ? "Применение..." : "DB => Router"}
</Button>
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
<SaveIcon className="size-4" />Сохранить в БД
</Button>
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
<PlusIcon className="size-4" />Добавить
</Button>
</>
}
/>
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">Всего маршрутов</span>
<span className="font-semibold tabular-nums">{rows.length}</span>
</div>
<div className="w-px h-4 bg-border mx-1 shrink-0" />
{servers.map((s) => {
const count = s.id === selectedServerId ? rows.length : 0
const active = selectedServerId === s.id
return (
<button key={s.id}
onClick={() => setSelectedServerId(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
!s.enabled && !active && "opacity-40",
)}>
<StatusDot status={s.status} />
<Flag code={s.country} size={12} />
<span className="font-mono">{s.name}</span>
<TypeChip type={s.type} />
<span className={cn(
"tabular-nums font-semibold",
active ? "" : count > 0 ? "text-foreground" : "opacity-40",
)}>{count}</span>
</button>
)
})}
</div>
<div className="px-6 py-3 flex items-center gap-3 border-b flex-wrap shrink-0">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
</button>
)}
</div>
<p className="text-xs text-muted-foreground ml-auto">
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{opError && (
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{opError}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-6">
{!isLive ? (
<Card className="p-6 text-sm text-muted-foreground">
Раздел работает в режиме &quot;Живые данные&quot;. Переключи источник данных в настройках.
</Card>
) : (
<Card className="overflow-hidden py-0 gap-0">
{currentServer && (
<div className="flex items-center gap-2.5 px-4 py-3 border-b bg-muted/10">
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
<Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} />
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
{currentServer.latency !== null && (
<span className={cn(
"text-xs font-mono",
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
)}>{currentServer.latency}мс</span>
)}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{rows.length} маршрутов</span>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Route / Comment</th>
<th className="text-left font-medium px-4 py-3">Gateways</th>
<th className="text-left font-medium px-4 py-3">EP</th>
<th className="text-left font-medium px-4 py-3">Priority</th>
<th className="text-left font-medium px-4 py-3">Table</th>
<th className="w-10 px-3 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{groupedRoutes.map((g) => (
<RouteGroupRows
key={g.key}
group={g}
expanded={expandedGroupKey === g.key}
onToggle={() => setExpandedGroupKey(prev => prev === g.key ? null : g.key)}
onEdit={() => openEdit(g)}
onDelete={() => setRows(prev => prev.filter(r => groupKeyOf(r) !== g.key))}
/>
))}
</tbody>
</table>
{groupedRoutes.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
Нет маршрутов в БД для этого сервера. Нажми &quot;Router =&gt; DB&quot; для загрузки.
</div>
)}
</div>
<button onClick={openCreate}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить маршрут
</button>
</Card>
)}
</div>
<RouteSheet
open={sheetOpen}
mode={sheetMode}
initial={sheetInitial}
onSave={handleSaveSheet}
onClose={() => setSheetOpen(false)}
gateways={gatewayOptions}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
"use client"
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
import { cn } from "@/lib/utils"
// ─── types ────────────────────────────────────────────────────────────────────
/** Unified server representation for the terminal sidebar */
interface TermServer {
uid: string // unique key (mock id or String(backend id))
backendId: number | null // null in mock mode
name: string
host: string
country: string
status: "online" | "offline" | "degraded" | null
enabled: boolean
rosVersion: string | null
identityName: string | null
}
interface TermLine {
id: number
kind: "output" | "prompt" | "error" | "info"
text: string
}
// ─── mock CLI responses ───────────────────────────────────────────────────────
type CmdFn = (args: string[], serverName: string) => string
const MOCK_COMMANDS: Record<string, CmdFn> = {
"ip address print": () =>
"Flags: X - disabled, I - invalid, D - dynamic\n" +
" # ADDRESS NETWORK INTERFACE\n" +
" 0 10.0.0.1/24 10.0.0.0 ether1-wan\n" +
" 1 192.168.88.1/24 192.168.88.0 bridge-lan\n" +
" 2 10.200.0.1/30 10.200.0.0 gre-msk-spb\n" +
" 3 10.200.0.5/30 10.200.0.4 gre-msk-fra\n" +
" 4 D 169.254.0.0/16 169.254.0.0 lo0",
"ip route print": () =>
"Flags: A - active, D - dynamic, C - connect, S - static, b - bgp, o - ospf\n" +
" # DST-ADDRESS PREF-SRC GATEWAY DIST\n" +
" 0 ADS 0.0.0.0/0 10.0.0.254 1\n" +
" 1 Ab 1.1.1.0/24 10.0.1.1 20\n" +
" 2 Ab 8.8.8.0/24 10.0.1.1 20\n" +
" 3 ADC 10.0.0.0/24 10.0.0.1 0\n" +
" 4 ADC 10.200.0.0/30 10.200.0.1 0",
"interface print": () =>
"Flags: X - disabled, D - dynamic, R - running\n" +
" # NAME TYPE MTU\n" +
" 0 R ether1-wan ether 1500\n" +
" 1 R ether2-lan ether 1500\n" +
" 2 R bridge-lan bridge 1500\n" +
" 3 R gre-msk-spb gre 1476\n" +
" 4 R gre-msk-fra gre 1476\n" +
" 5 gre-msk-ams gre 1476",
"system resource print": () =>
" uptime: 12d 4h 22m 18s\n" +
" version: 7.14.3 (stable)\n" +
" free-memory: 512.0 MiB\n" +
" total-memory: 2048.0 MiB\n" +
" cpu: Intel(R) Xeon(R)\n" +
" cpu-count: 4\n" +
" cpu-frequency: 3200 MHz\n" +
" cpu-load: 12%\n" +
" free-hdd-space: 8192.0 MiB\n" +
" total-hdd-space: 16384.0 MiB\n" +
" architecture-name: x86\n" +
" board-name: CHR\n" +
" platform: MikroTik",
"system identity print": (_, n) => ` name: ${n}`,
"ip firewall filter print": () =>
"Flags: X - disabled, I - invalid, D - dynamic\n\n" +
" 0 \n ;;; Accept established connections\n" +
" chain=input action=accept connection-state=established,related\n\n" +
" 1 \n ;;; YouTube bypass traffic\n" +
" chain=forward action=accept src-address-list=youtube-bypass protocol=tcp dst-port=443\n" +
" packets=188421 bytes=14283698176\n\n" +
" 2 \n ;;; Block SSH from WAN\n" +
" chain=input action=drop in-interface=ether1-wan protocol=tcp dst-port=22\n" +
" packets=882412 bytes=54618112",
"routing bgp session print": () =>
"Flags: E - established\n" +
" # NAME REMOTE-AS REMOTE-ADDRESS STATE UPTIME\n" +
" 0 E peer-spb-01 65002 10.0.1.1 established 12d 4h 22m\n" +
" 1 E peer-fra-01 65003 10.0.2.1 established 9d 12h 11m\n" +
" 2 peer-ams-01 65004 10.0.3.1 active —",
"routing ospf neighbor print": () =>
"Flags: V - virtual\n" +
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE\n" +
" 0 10.0.0.7 Full 8 12d 4h 18m gre-msk-spb\n" +
" 1 10.0.1.1 Full 3 9d 11h 42m gre-msk-fra",
"routing bfd session print": () =>
" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME\n" +
" 0 10.200.0.1%gre-msk-spb 10.200.0.2%gre-msk-spb up 12d 4h\n" +
" 1 10.200.1.1%gre-msk-fra 10.200.1.2%gre-msk-fra up 9d 11h",
"log print": () =>
"may/01 08:14:22 ospf,debug,packet GRE-MSK-FRA: hello received, RouterID: 10.0.2.1\n" +
"may/01 08:14:21 bgp,debug peer-spb-01 sending UPDATE\n" +
"may/01 08:14:18 system,info user admin logged from 10.10.0.5\n" +
"may/01 08:12:44 firewall,info forward: in:ether2-lan out:gre-msk-fra proto TCP\n" +
"may/01 08:11:03 script,info backup-script: backup saved to /backup/mt.rsc\n" +
"may/01 08:09:17 system,warning cpu load is 85% on cpu0",
"ping": (args) => {
const host = args[0] || "8.8.8.8"
const count = parseInt(args.find(a => a.startsWith("count="))?.split("=")[1] || "4")
const rtt = Math.round(10 + Math.random() * 50)
const lines = [`PING ${host}`]
for (let i = 0; i < Math.min(count, 5); i++) {
const t = rtt + Math.round((Math.random() - 0.5) * 8)
lines.push(` seq=${i} ttl=56 time=${t}ms`)
}
lines.push(` sent=${count} received=${count} packet-loss=0%`)
return lines.join("\n")
},
}
function mockResolve(raw: string, serverName: string): string {
const trimmed = raw.trim()
if (!trimmed) return ""
if (trimmed === "?" || trimmed === "help")
return "ip address print | ip route print | interface print\n" +
"system resource print | ip firewall filter print\n" +
"routing bgp session print | routing ospf neighbor print\n" +
"routing bfd session print | log print | ping <host>"
if (trimmed === "clear" || trimmed === "/clear") return "__CLEAR__"
if (trimmed === "quit" || trimmed === "exit") return "Connection closed."
const key = Object.keys(MOCK_COMMANDS).find(k =>
trimmed === k || trimmed.toLowerCase().startsWith(k + " ")
)
if (key) {
const rest = trimmed.slice(key.length).trim().split(/\s+/)
return MOCK_COMMANDS[key](rest, serverName)
}
return `bad command name ${trimmed.split(" ")[0]} (line 1 column 1)`
}
// ─── MOTD builders ────────────────────────────────────────────────────────────
function mockMotd(server: TermServer): string {
return [
"",
" MMM MMM KKK TTTTTTTTTTT KKK",
" MMM MMMM MMM III KKK KKK RRR OOOOOO TTT TTT II KKK KKK",
" MMM MMM III KKK KKK RRR RRROOOOO TTT TTT II KKK KKK",
"",
` MikroTik RouterOS ${server.rosVersion ?? "7.14.3"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
"",
"[?] Gives the list of available commands",
"[Tab] Completes the command/word",
"[/] Move up to base level",
"[..] Move up one level",
"",
].join("\n")
}
function liveMotd(server: TermServer): string {
return [
"",
` MikroTik RouterOS ${server.rosVersion ?? "7.x"} (c) 1999-${new Date().getFullYear()} https://www.mikrotik.com/`,
"",
` Connected to ${server.name} (${server.host})`,
"",
"[?] Gives the list of available commands — type help or ?",
"[Tab] Completes the command/word",
"[/] Move up to base level",
"",
].join("\n")
}
// ─── terminal component ───────────────────────────────────────────────────────
function Terminal({
server,
isLive,
backendUrl,
}: {
server: TermServer
isLive: boolean
backendUrl: string
}) {
const [lines, setLines] = useState<TermLine[]>([])
const [input, setInput] = useState("")
const [history, setHistory] = useState<string[]>([])
const [histIdx, setHistIdx] = useState(-1)
const [idCtr, setIdCtr] = useState(0)
const [executing, setExecuting] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const nextId = useCallback((): number => {
let id = 0
setIdCtr(prev => { id = prev + 1; return prev + 1 })
return id
}, [])
const addLines = useCallback((texts: string[], kind: TermLine["kind"] = "output") => {
setLines(prev => [
...prev,
...texts.map(text => ({ id: 0, kind, text })),
].map((l, i, arr) => ({ ...l, id: arr.length - texts.length + i }))
// Note: IDs don't need to be perfect unique here since we append
)
setIdCtr(prev => prev + texts.length)
}, [])
// Initialize MOTD on mount / server change
useEffect(() => {
const motd = isLive ? liveMotd(server) : mockMotd(server)
const init = motd.split("\n").map((text, i) => ({ id: i, kind: "output" as const, text }))
setLines(init)
setIdCtr(init.length)
setInput("")
setHistory([])
setHistIdx(-1)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.uid, isLive])
// Auto-scroll
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
}, [lines])
const prompt = `[admin@${server.identityName ?? server.name}] > `
const submit = useCallback(async () => {
if (executing) return
const cmd = input.trim()
setLines(prev => [...prev, { id: nextId(), kind: "prompt", text: prompt + input }])
setInput("")
setHistIdx(-1)
if (!cmd) return
setHistory(h => [cmd, ...h.filter(x => x !== cmd)].slice(0, 100))
// local always-available commands
const lower = cmd.toLowerCase()
if (lower === "clear" || lower === "/clear") { setLines([]); return }
if (lower === "quit" || lower === "exit") {
setLines(prev => [...prev, { id: nextId(), kind: "info", text: "Connection closed." }])
return
}
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind, text: line }])
)
} catch (err) {
setLines(prev => [...prev, { id: nextId(), kind: "error", text: `network error: ${String(err)}` }])
} finally {
setExecuting(false)
}
} else {
// mock mode
const result = mockResolve(cmd, server.name)
if (result === "__CLEAR__") { setLines([]); return }
if (result) {
result.split("\n").forEach(line =>
setLines(prev => [...prev, { id: nextId(), kind: "output", text: line }])
)
}
}
}, [executing, input, nextId, prompt, isLive, server, backendUrl])
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault(); void submit()
} else if (e.key === "ArrowUp") {
e.preventDefault()
setHistIdx(i => {
const next = Math.min(i + 1, history.length - 1)
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "ArrowDown") {
e.preventDefault()
setHistIdx(i => {
const next = i - 1
if (next < 0) { setInput(""); return -1 }
if (history[next] !== undefined) setInput(history[next])
return next
})
} else if (e.key === "l" && e.ctrlKey) {
e.preventDefault(); setLines([])
}
}
const lineColor = (kind: TermLine["kind"]) => {
if (kind === "error") return "text-red-400"
if (kind === "prompt") return "text-emerald-400"
if (kind === "info") return "text-sky-400"
return "text-[#c9d1d9]"
}
return (
<div
className="flex flex-col bg-[#0d1117] rounded-lg border border-[#30363d] overflow-hidden font-mono text-xs h-full"
onClick={() => inputRef.current?.focus()}
>
{/* title bar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-[#30363d] bg-[#161b22] shrink-0">
<CircleIcon className="size-3 text-red-500 fill-red-500" />
<CircleIcon className="size-3 text-amber-400 fill-amber-400" />
<CircleIcon className="size-3 text-emerald-500 fill-emerald-500" />
<span className="mx-auto text-[#8b949e] text-[11px]">
{server.name} {isLive ? "RouterOS REST" : "SSH Terminal (mock)"}
</span>
{isLive && (
<span className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium border border-emerald-500/30 bg-emerald-500/10 text-emerald-400">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
<button
onClick={e => { e.stopPropagation(); setLines([]) }}
className="text-[#8b949e] hover:text-white transition-colors ml-1"
title="Очистить"
>
<TrashIcon className="size-3.5" />
</button>
</div>
{/* output */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-0.5 min-h-0">
{lines.map((line, i) => (
<div key={`${line.id}-${i}`} className={cn("whitespace-pre-wrap break-all leading-5", lineColor(line.kind))}>
{line.text}
</div>
))}
{/* executing indicator */}
{executing && (
<div className="flex items-center gap-2 text-amber-400 opacity-80">
<Loader2Icon className="size-3 animate-spin" />
<span>executing</span>
</div>
)}
{/* input line */}
{!executing && (
<div className="flex items-center gap-0">
<span className="text-emerald-400 select-none shrink-0">{prompt}</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKey}
className="terminal-input-active flex-1 bg-transparent outline-none text-[#c9d1d9] caret-emerald-400"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
</div>
)}
</div>
</div>
)
}
// ─── quick commands ───────────────────────────────────────────────────────────
const QUICK_CMDS = [
{ cmd: "system resource print", label: "system resource print" },
{ cmd: "interface print", label: "interface print" },
{ cmd: "ip address print", label: "ip address print" },
{ cmd: "ip route print", label: "ip route print" },
{ cmd: "ip firewall filter print", label: "ip firewall filter print" },
{ cmd: "routing bgp session print", label: "routing bgp session print" },
{ cmd: "routing ospf neighbor print", label: "routing ospf neighbor print" },
{ cmd: "routing ospf area print", label: "routing ospf area print" },
{ cmd: "routing bfd session print", label: "routing bfd session print" },
{ cmd: "log print", label: "log print" },
]
// ─── page ─────────────────────────────────────────────────────────────────────
/** Convert mock server list to TermServer[] */
function mockServersToTermServers(): TermServer[] {
return mockServers.map(s => ({
uid: s.id,
backendId: null,
name: s.name,
host: s.host,
country: s.country,
status: s.status as TermServer["status"],
enabled: s.enabled,
rosVersion: s.os ?? null,
identityName: s.name,
}))
}
/** Backend ServerRead shape (only fields we need) */
interface BackendServer {
id: number
name: string
host: string
country: string
status: "online" | "offline" | null
enabled: boolean
os: string | null
identityName: string | null
}
export default function TerminalPage() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
// Server list state
const [liveServers, setLiveServers] = useState<TermServer[]>([])
const [serversLoading, setServersLoading] = useState(false)
const [refreshKey, setRefreshKey] = useState(0) // force terminal remount on reconnect
// Load servers from backend when in live mode
useEffect(() => {
if (!isLive) { setLiveServers([]); return }
let cancelled = false
setServersLoading(true)
fetch(`${backendUrl}/api/servers`)
.then(r => r.json() as Promise<BackendServer[]>)
.then(data => {
if (cancelled) return
setLiveServers(data.map(s => ({
uid: String(s.id),
backendId: s.id,
name: s.name || s.host,
host: s.host,
country: s.country || "",
status: s.status,
enabled: s.enabled,
rosVersion: s.os,
identityName: s.identityName,
})))
setServersLoading(false)
})
.catch(() => { if (!cancelled) setServersLoading(false) })
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
// Select first available server by default
const [selectedUid, setSelectedUid] = useState<string>("")
const defaultUid = useMemo(() => {
const first = termServers.find(s => s.status !== "offline" && s.enabled)
?? termServers[0]
return first?.uid ?? ""
}, [termServers])
useEffect(() => {
if (!selectedUid && defaultUid) setSelectedUid(defaultUid)
}, [defaultUid, selectedUid])
// Reset selection when switching modes
useEffect(() => { setSelectedUid("") }, [isLive])
const selected = termServers.find(s => s.uid === selectedUid) ?? termServers[0]
const termKey = `${selectedUid}-${refreshKey}-${isLive ? "live" : "mock"}`
function injectCommand(cmd: string) {
const el = document.querySelector<HTMLInputElement>(".terminal-input-active")
if (!el) return
const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set
nativeSetter?.call(el, cmd)
el.dispatchEvent(new Event("input", { bubbles: true }))
el.focus()
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Терминал" }]}
actions={
<Button variant="outline" size="sm" onClick={() => {
setRefreshKey(k => k + 1)
if (isLive) setSelectedUid("")
}}>
<RefreshCwIcon className="size-4" />Переподключить
</Button>
}
/>
<div className="flex-1 overflow-hidden p-6">
<div className="grid grid-cols-[220px_1fr] gap-5 h-full">
{/* ── sidebar ── */}
<div className="flex flex-col gap-4 overflow-y-auto min-h-0">
{/* server picker */}
<div>
<div className="flex items-center gap-2 mb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Узел</p>
{isLive && serversLoading && (
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
)}
{isLive && !serversLoading && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5">
<span className="size-1 rounded-full bg-emerald-400" />LIVE
</span>
)}
</div>
{isLive && !serversLoading && liveServers.length === 0 && (
<p className="text-xs text-muted-foreground px-2.5">
Нет доступных серверов
</p>
)}
<div className="space-y-1">
{termServers.map(s => {
const isOffline = s.status === "offline"
const isSelected = s.uid === selectedUid
return (
<button
key={s.uid}
disabled={isOffline || !s.enabled}
onClick={() => { setSelectedUid(s.uid); setRefreshKey(k => k + 1) }}
className={cn(
"w-full text-left rounded-md px-2.5 py-2 text-xs transition-colors",
"flex items-center gap-2",
isSelected
? "bg-primary text-primary-foreground"
: "hover:bg-muted",
(isOffline || !s.enabled) && "opacity-40 cursor-not-allowed",
)}
>
<span className={cn(
"inline-block size-1.5 rounded-full shrink-0",
s.status === "online" ? "bg-emerald-500" :
s.status === "degraded" ? "bg-amber-400" :
s.status === null ? "bg-sky-400" : "bg-red-500",
)} />
{s.country && <Flag code={s.country} />}
<span className="truncate font-mono">{s.name}</span>
</button>
)
})}
</div>
</div>
{/* quick commands */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Быстрые команды
</p>
<div className="space-y-1">
{QUICK_CMDS.map(({ cmd, label }) => (
<button
key={cmd}
className="w-full text-left rounded-md px-2.5 py-1.5 text-[11px] font-mono text-muted-foreground hover:bg-muted hover:text-foreground transition-colors truncate block"
onClick={() => injectCommand(cmd)}
title={cmd}
>
{label}
</button>
))}
</div>
</div>
{/* hints */}
<div className="mt-auto text-[10px] text-muted-foreground/50 space-y-0.5 px-0.5">
<p> история команд</p>
<p>Ctrl+L очистить экран</p>
{isLive
? <p className="text-sky-400/60">Команды выполняются на роутере</p>
: <p>Режим: mock-данные</p>
}
</div>
</div>
{/* ── terminal ── */}
{selected ? (
<Terminal
key={termKey}
server={selected}
isLive={isLive}
backendUrl={backendUrl}
/>
) : (
<div className="flex items-center justify-center bg-[#0d1117] rounded-lg border border-[#30363d] text-[#8b949e] text-sm font-mono">
{serversLoading ? "Загрузка серверов…" : "Выберите сервер"}
</div>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+395
View File
@@ -0,0 +1,395 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { vxlanTunnels, servers } from "@/lib/data"
import type { VxlanTunnel } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
SearchIcon, NetworkIcon, PlusIcon, MoreHorizontalIcon,
Trash2Icon, PencilIcon, PowerIcon, CopyIcon, CheckIcon,
CodeXmlIcon, LayersIcon,
} from "lucide-react"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
// ─── helpers ──────────────────────────────────────────────────────────────────
function serverFor(id: string) {
return servers.find((s) => s.id === id)
}
// ─── RSC generator ───────────────────────────────────────────────────────────
function generateVxlanRsc(t: VxlanTunnel): string {
const srv = serverFor(t.serverId)
const lines: string[] = []
lines.push(`# VXLAN — ${t.name} · VNI ${t.vni}`)
if (srv) lines.push(`# Сервер: ${srv.name} (${srv.host})`)
lines.push(`# RouterOS 7.x · /interface/vxlan`)
lines.push(``)
lines.push(`/interface/vxlan/add \\`)
lines.push(` name=${t.name} \\`)
lines.push(` vni=${t.vni} \\`)
lines.push(` port=${t.dstPort} \\`)
lines.push(` vtep-mac-address=auto \\`)
lines.push(` arp-proxy=${t.arpProxy ? "yes" : "no"} \\`)
lines.push(` mac-learning=${t.macLearning ? "yes" : "no"} \\`)
lines.push(` l2mtu=${t.l2mtu} \\`)
if (t.comment) lines.push(` comment="${t.comment}" \\`)
if (!t.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
// FDB entries for remote VTEPs
for (const vtep of t.remoteVteps) {
lines.push(`/interface/vxlan/vteps/add \\`)
lines.push(` interface=${t.name} \\`)
lines.push(` remote-ip=${vtep}`)
lines.push(``)
}
// Bridge
lines.push(`# Добавить в bridge:`)
lines.push(`/interface/bridge/port/add \\`)
lines.push(` bridge=bridge-overlay \\`)
lines.push(` interface=${t.name}`)
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, tunnel, onClose }: {
open: boolean; tunnel: VxlanTunnel | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => tunnel ? generateVxlanRsc(tunnel) : "", [tunnel])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт VXLAN</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface/vxlan + vteps</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied ? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</> : <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = /^\//.test(line.trimStart())
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ─── Tunnel row ───────────────────────────────────────────────────────────────
function TunnelRow({
tunnel,
onExport,
}: {
tunnel: VxlanTunnel
onExport: () => void
}) {
const srv = serverFor(tunnel.serverId)
return (
<div className={cn(
"grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 transition-colors",
!tunnel.enabled && "opacity-50",
)}>
{/* status dot */}
<span className={cn(
"size-2 rounded-full shrink-0",
tunnel.status === "up" ? "bg-emerald-500" : "bg-red-500",
)} />
{/* name */}
<div className="min-w-0">
<p className="font-mono font-medium text-sm truncate">{tunnel.name}</p>
<p className="text-[11px] text-muted-foreground font-mono">VTEP: {tunnel.vtepIp}</p>
</div>
{/* server */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0">
{srv && <><Flag code={srv.country} size={12} /><span className="font-mono truncate">{srv.name}</span></>}
</div>
{/* VNI */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">VNI</p>
<p className="font-mono text-sm">{tunnel.vni}</p>
</div>
{/* Port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Port</p>
<p className="font-mono text-sm">{tunnel.dstPort}</p>
</div>
{/* Remote VTEPs */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Remote VTEP</p>
<p className="font-mono text-sm">{tunnel.remoteVteps.length}</p>
</div>
{/* ARP Proxy */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.arpProxy ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" : "bg-muted text-muted-foreground")}>
ARP {tunnel.arpProxy ? "✓" : "✗"}
</span>
{/* MAC learning */}
<span className={cn("text-[10px] font-mono px-1.5 py-0.5 rounded",
tunnel.macLearning ? "bg-sky-500/10 text-sky-600 dark:text-sky-400" : "bg-muted text-muted-foreground")}>
MAC {tunnel.macLearning ? "✓" : "✗"}
</span>
{/* Status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border whitespace-nowrap",
tunnel.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{tunnel.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7">
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={onExport}><CodeXmlIcon className="size-4" />Экспорт .rsc</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{tunnel.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function VxlanPage() {
const [search, setSearch] = useState("")
const [exportTunnel, setExportTunnel] = useState<VxlanTunnel | null>(null)
const filtered = useMemo(() => {
if (!search) return vxlanTunnels
const q = search.toLowerCase()
return vxlanTunnels.filter((t) =>
t.name.includes(q) ||
String(t.vni).includes(q) ||
t.vtepIp.includes(q) ||
(serverFor(t.serverId)?.name.toLowerCase().includes(q) ?? false)
)
}, [search])
const upCount = vxlanTunnels.filter((t) => t.status === "up").length
const vnis = new Set(vxlanTunnels.map((t) => t.vni)).size
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "VXLAN" }]}
actions={
<Button size="sm">
<PlusIcon className="size-4" />Новый VXLAN
</Button>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Туннелей", value: vxlanTunnels.length, icon: <NetworkIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных", value: upCount, icon: <LayersIcon className="size-4 text-emerald-500" /> },
{ label: "Уникальных VNI", value: vnis, icon: <LayersIcon className="size-4 text-sky-400" /> },
{ label: "Серверов", value: new Set(vxlanTunnels.map((t) => t.serverId)).size, icon: <NetworkIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<NetworkIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">VXLAN L2-over-L3 оверлей для RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. VNI (Virtual Network Identifier) уникальный идентификатор сегмента (016777215).
Рекомендуется использовать совместно с WireGuard или GRE туннелями для шифрования.
</p>
</div>
</div>
{/* Table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[240px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, VNI, серверу…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} туннелей</span>
</div>
{/* header */}
<div className="grid grid-cols-[10px_1fr_1fr_auto_auto_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Имя / VTEP IP</span>
<span>Сервер</span>
<span>VNI</span>
<span>Port</span>
<span>Remote</span>
<span />
<span />
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<NetworkIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">VXLAN туннели не найдены</p>
</div>
) : (
filtered.map((t) => (
<TunnelRow key={t.id} tunnel={t} onExport={() => setExportTunnel(t)} />
))
)}
</Card>
{/* Reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface/vxlan быстрые команды
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать VXLAN",
lines: [
"/interface/vxlan/add \\",
" name=vxlan-10 \\",
" vni=10010 \\",
" port=8472 \\",
" arp-proxy=yes \\",
" mac-learning=yes",
],
},
{
title: "Добавить VTEP",
lines: [
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.1.1",
"",
"/interface/vxlan/vteps/add \\",
" interface=vxlan-10 \\",
" remote-ip=10.0.2.1",
],
},
{
title: "Bridge + IP",
lines: [
"/interface/bridge/add \\",
" name=br-overlay",
"",
"/interface/bridge/port/add \\",
" bridge=br-overlay \\",
" interface=vxlan-10",
"",
"/ip/address/add \\",
" address=10.100.0.1/24 \\",
" interface=br-overlay",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto whitespace-pre">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<ExportSheet
open={!!exportTunnel}
tunnel={exportTunnel}
onClose={() => setExportTunnel(null)}
/>
</div>
)
}
+492
View File
@@ -0,0 +1,492 @@
"use client"
import { useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers } from "@/lib/data"
import type { WireGuardInterface, WireGuardPeer } from "@/lib/data"
import { Flag } from "@/components/flag"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import {
ShieldCheckIcon, PlusIcon, SearchIcon, KeyRoundIcon,
ChevronDownIcon, ChevronRightIcon, MoreHorizontalIcon,
PencilIcon, Trash2Icon, PowerIcon, CopyIcon, CheckIcon,
CodeXmlIcon, UsersIcon, ActivityIcon, ArrowDownIcon, ArrowUpIcon,
} from "lucide-react"
// ─── collect all WireGuard interfaces from all servers ────────────────────────
interface WgIfaceWithServer extends WireGuardInterface {
serverId: string
serverName: string
serverCountry: string
}
function collectInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = []
for (const srv of servers) {
for (const wg of srv.wireGuardIfaces ?? []) {
result.push({
...wg,
serverId: srv.id,
serverName: srv.name,
serverCountry: srv.country,
})
}
}
return result
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function fmtBytes(n: number | undefined): string {
if (!n) return "—"
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
return `${n} Б`
}
function truncKey(key: string): string {
if (key.length <= 20) return key
return `${key.slice(0, 8)}${key.slice(-8)}`
}
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateWgRsc(iface: WgIfaceWithServer): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
lines.push(`# RouterOS 7.x`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
if (!iface.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
if (p.comment) lines.push(` comment="${p.comment}" \\`)
lines.push(``)
}
return lines.join("\n")
}
// ─── Peer row ─────────────────────────────────────────────────────────────────
function PeerRow({ peer }: { peer: WireGuardPeer }) {
return (
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20">
{/* public key */}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
{/* allowed IPs */}
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
{/* handshake */}
<span className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
{/* rx / tx */}
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />{fmtBytes(peer.transferTx)}
</span>
</div>
{/* endpoint */}
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
)
}
// ─── Interface card ───────────────────────────────────────────────────────────
function IfaceRow({
iface,
expanded,
onToggleExpand,
onExport,
}: {
iface: WgIfaceWithServer
expanded: boolean
onToggleExpand: () => void
onExport: () => void
}) {
const onlinePeers = iface.peers.filter((p) => !!p.latestHandshake).length
return (
<div className={cn("border-b last:border-b-0", !iface.enabled && "opacity-50")}>
<div
className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-3 items-center hover:bg-muted/30 transition-colors cursor-pointer"
onClick={onToggleExpand}
>
{/* expand */}
<button className="text-muted-foreground" onClick={(e) => { e.stopPropagation(); onToggleExpand() }}>
{expanded
? <ChevronDownIcon className="size-3.5" />
: <ChevronRightIcon className="size-3.5" />}
</button>
{/* name + server */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"size-2 rounded-full shrink-0",
iface.status === "up" ? "bg-emerald-500 animate-pulse" : "bg-red-500",
)} />
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
{iface.serverName}
</div>
</div>
{/* port */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Порт</p>
<p className="font-mono text-sm">{iface.listenPort}</p>
</div>
{/* MTU */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">MTU</p>
<p className="font-mono text-sm">{iface.mtu}</p>
</div>
{/* peers */}
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Пиров</p>
<p className="font-mono text-sm">
<span className="text-emerald-600 dark:text-emerald-400">{onlinePeers}</span>
<span className="text-muted-foreground">/{iface.peers.length}</span>
</p>
</div>
{/* status badge */}
<span className={cn(
"text-[11px] font-mono px-2 py-0.5 rounded border",
iface.status === "up"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-red-500/10 text-red-500 border-red-500/20",
)}>
{iface.status === "up" ? "UP" : "DOWN"}
</span>
{/* menu */}
<DropdownMenu>
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon" className="size-7" onClick={(e) => e.stopPropagation()}>
<MoreHorizontalIcon className="size-4" />
</Button>
} />
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onExport() }}>
<CodeXmlIcon className="size-4" />Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem><PencilIcon className="size-4" />Редактировать</DropdownMenuItem>
<DropdownMenuItem><PlusIcon className="size-4" />Добавить пира</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem><PowerIcon className="size-4" />{iface.enabled ? "Отключить" : "Включить"}</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive"><Trash2Icon className="size-4" />Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* expanded peers */}
{expanded && iface.peers.length > 0 && (
<div>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-4 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-t border-border/50">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
</div>
{iface.peers.map((p) => <PeerRow key={p.publicKey} peer={p} />)}
</div>
)}
{expanded && iface.peers.length === 0 && (
<div className="px-4 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
Нет пиров
</div>
)}
</div>
)
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
function ExportSheet({ open, iface, onClose }: {
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт WireGuard</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface")
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const [search, setSearch] = useState("")
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const filtered = useMemo(() => {
if (!search) return allIfaces
const q = search.toLowerCase()
return allIfaces.filter((i) =>
i.name.includes(q) ||
i.serverName.toLowerCase().includes(q) ||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q))
)
}, [allIfaces, search])
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
const upIfaces = allIfaces.filter((i) => i.status === "up").length
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<Button size="sm">
<PlusIcon className="size-4" />Новый интерфейс
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Card key={s.label}>
<CardContent className="px-5 py-4 flex items-start justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums mt-0.5">{s.value}</p>
</div>
<div className="mt-0.5">{s.icon}</div>
</CardContent>
</Card>
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard рекомендуемый туннельный протокол в RouterOS 7.x</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
</p>
</div>
</div>
{/* Search + table */}
<Card>
<div className="flex items-center gap-3 px-4 py-3 border-b">
<div className="flex items-center gap-2 h-8 px-3 border border-input rounded-md bg-background min-w-[260px]">
<SearchIcon className="size-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
placeholder="Поиск по имени, серверу, IP…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<span className="text-sm text-muted-foreground ml-auto">{filtered.length} интерфейсов</span>
</div>
{/* table header */}
<div className="grid grid-cols-[20px_1fr_auto_auto_auto_auto_auto_auto] gap-3 px-4 py-2 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground bg-muted/20">
<span />
<span>Интерфейс / Сервер</span>
<span>Порт</span>
<span>MTU</span>
<span>Пиры</span>
<span>Статус</span>
<span />
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<ShieldCheckIcon className="size-10 mb-3 opacity-20" />
<p className="text-sm font-medium">Нет WireGuard интерфейсов</p>
<p className="text-xs mt-1">Добавьте первый интерфейс или проверьте поиск</p>
</div>
) : (
filtered.map((iface) => (
<IfaceRow
key={iface.id}
iface={iface}
expanded={expandedIds.has(iface.id)}
onToggleExpand={() => toggleExpand(iface.id)}
onExport={() => setExportIface(iface)}
/>
))
)}
</Card>
{/* RouterOS reference */}
<Card>
<CardContent className="px-5 py-4">
<p className="text-xs font-medium text-muted-foreground mb-3">
RouterOS 7 · /interface wireguard быстрые команды
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
<ExportSheet
open={!!exportIface}
iface={exportIface}
onClose={() => setExportIface(null)}
/>
</div>
)
}
+190
View File
@@ -0,0 +1,190 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ─── Light theme ─────────────────────────────────────────────────────────── */
:root {
/* Base — subtle blue-gray tint so cards (white) float above the page */
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.141 0.006 264.0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.006 264.0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.006 264.0);
/* Primary — dark charcoal for buttons / key UI */
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
/* Secondary / muted — very light blue-gray */
--secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.520 0.010 264.0);
/* Accent — soft blue highlight */
--accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
/* Borders + inputs */
--border: oklch(0.904 0.006 264.0);
--input: oklch(0.904 0.006 264.0);
/* Focus ring — blue, matching the brand accent */
--ring: oklch(0.488 0.243 264.376);
/* ── Colorful chart palette (light) ── */
--chart-1: oklch(0.546 0.215 264.376); /* blue */
--chart-2: oklch(0.527 0.154 150.069); /* emerald */
--chart-3: oklch(0.625 0.162 41.500); /* orange */
--chart-4: oklch(0.591 0.224 296.400); /* violet */
--chart-5: oklch(0.577 0.245 27.325); /* red */
/* ── Semantic line colors for charts (light) ── */
--chart-line-1: oklch(0.527 0.154 150.069); /* green — MSK / RX */
--chart-line-2: oklch(0.546 0.215 264.376); /* blue — SPB / TX */
--chart-line-3: oklch(0.625 0.162 41.500); /* orange — FRA */
--chart-line-4: oklch(0.591 0.224 296.400); /* violet — AMS */
--chart-rx: oklch(0.527 0.154 150.069);
--chart-tx: oklch(0.546 0.215 264.376);
--radius: 0.625rem;
/* ── Sidebar — intentionally dark even in light mode ── */
--sidebar: oklch(0.152 0.010 264.376);
--sidebar-foreground: oklch(0.920 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.240 0.012 264.376);
--sidebar-accent-foreground: oklch(0.950 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* ── Status ── */
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
}
/* ─── Dark theme ──────────────────────────────────────────────────────────── */
.dark {
/* Base — blue-tinted dark for modern network terminal aesthetic */
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
/* Primary — bright white for dark backgrounds */
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
/* Secondary / muted — dark blue-gray */
--secondary: oklch(0.252 0.010 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.010 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
/* Accent — slightly lighter blue-tinted dark */
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
/* ── Colorful chart palette (dark — brighter for visibility) ── */
--chart-1: oklch(0.637 0.222 264.376); /* bright blue */
--chart-2: oklch(0.696 0.170 151.328); /* bright emerald */
--chart-3: oklch(0.713 0.176 47.600); /* bright orange */
--chart-4: oklch(0.714 0.173 303.900); /* bright violet */
--chart-5: oklch(0.704 0.191 22.216); /* bright red */
/* ── Semantic line colors (dark) ── */
--chart-line-1: oklch(0.696 0.170 151.328); /* green */
--chart-line-2: oklch(0.637 0.222 264.376); /* blue */
--chart-line-3: oklch(0.713 0.176 47.600); /* orange */
--chart-line-4: oklch(0.714 0.173 303.900); /* violet */
--chart-rx: oklch(0.696 0.170 151.328);
--chart-tx: oklch(0.637 0.222 264.376);
/* ── Sidebar — slightly lighter than bg in dark mode ── */
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
/* Status stays the same */
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Script from "next/script";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "RouterLists",
description: "MikroTik network management platform",
};
// Runs before React hydration — prevents flash of wrong theme (FOUC).
const ANTI_FOUC = `
try {
var t = localStorage.getItem('rl-theme') || 'dark';
var d = t === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: t;
document.documentElement.classList.toggle('dark', d === 'dark');
document.documentElement.classList.toggle('light', d === 'light');
} catch(e) {}
`.trim()
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="ru"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<Script id="anti-fouc" strategy="beforeInteractive">
{ANTI_FOUC}
</Script>
</head>
<body className="min-h-full flex flex-col">
<ThemeProvider defaultTheme="dark" disableTransitionOnChange>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}