Init commit
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
BellIcon, BellOffIcon, RefreshCwIcon, PlusIcon,
|
||||
CableIcon, NetworkIcon, RouteIcon, UserIcon, ServerIcon,
|
||||
TimerIcon, WifiOffIcon, TrendingDownIcon,
|
||||
EyeIcon, EyeOffIcon, CopyIcon, SendIcon, CheckIcon, XIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type AlertType = "gre-tunnel" | "bgp-peer" | "bgp-prefix" | "gre-client" | "server" | "rtt" | "loss" | "traffic"
|
||||
type AlertSeverity = "critical" | "warning" | "info"
|
||||
type AlertCooldown = "1м" | "5м" | "15м" | "1ч" | "4ч" | "24ч"
|
||||
|
||||
interface AlertRule {
|
||||
id: string
|
||||
name: string
|
||||
type: AlertType
|
||||
target: string // display label
|
||||
condition: string // display label
|
||||
severity: AlertSeverity
|
||||
enabled: boolean
|
||||
cooldown: AlertCooldown
|
||||
lastFired: string | null // relative time string
|
||||
chatId: string // override; empty = use global
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
ruleName: string
|
||||
severity: AlertSeverity
|
||||
message: string
|
||||
time: string
|
||||
sent: boolean
|
||||
}
|
||||
|
||||
interface TelegramConfig {
|
||||
token: string
|
||||
chatId: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
// ─── static config ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_META: Record<AlertType, {
|
||||
Icon: React.FC<{ className?: string }>
|
||||
label: string
|
||||
iconClass: string
|
||||
bg: string
|
||||
}> = {
|
||||
"gre-tunnel": { Icon: CableIcon, label: "GRE-туннель", iconClass: "text-violet-500", bg: "bg-violet-500/10" },
|
||||
"bgp-peer": { Icon: NetworkIcon, label: "BGP-сосед", iconClass: "text-blue-500", bg: "bg-blue-500/10" },
|
||||
"bgp-prefix": { Icon: RouteIcon, label: "BGP-префикс", iconClass: "text-sky-500", bg: "bg-sky-500/10" },
|
||||
"gre-client": { Icon: UserIcon, label: "GRE-клиент", iconClass: "text-purple-500", bg: "bg-purple-500/10" },
|
||||
"server": { Icon: ServerIcon, label: "Сервер", iconClass: "text-slate-500", bg: "bg-slate-500/10" },
|
||||
"rtt": { Icon: TimerIcon, label: "Задержка (RTT)", iconClass: "text-amber-500", bg: "bg-amber-500/10" },
|
||||
"loss": { Icon: WifiOffIcon, label: "Потери пакетов", iconClass: "text-orange-500", bg: "bg-orange-500/10" },
|
||||
"traffic": { Icon: TrendingDownIcon, label: "Низкий трафик", iconClass: "text-rose-500", bg: "bg-rose-500/10" },
|
||||
}
|
||||
|
||||
const SEVERITY_META: Record<AlertSeverity, { label: string; dot: string; badge: string; chip: string }> = {
|
||||
critical: {
|
||||
label: "Критическое",
|
||||
dot: "bg-red-500",
|
||||
badge: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||
chip: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
},
|
||||
warning: {
|
||||
label: "Предупреждение",
|
||||
dot: "bg-amber-400",
|
||||
badge: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
|
||||
chip: "border-amber-400 bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
info: {
|
||||
label: "Информационное",
|
||||
dot: "bg-blue-500",
|
||||
badge: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400",
|
||||
chip: "border-blue-400 bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
}
|
||||
|
||||
const TYPE_TARGETS: Record<AlertType, string[]> = {
|
||||
"gre-tunnel": ["gre-ivanov-01 / core-01", "gre-ivanov-01 / lab-01", "gre-ivanov-02 / core-01", "gre-ivanov-03 / lab-01", "gre-petrov-01 / core-01", "gre-petrov-01 / lab-01", "gre-kozlov-01 / core-01"],
|
||||
"bgp-peer": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
|
||||
"bgp-prefix": ["любой префикс", "185.13.0.0/22", "77.88.8.0/24", "8.8.8.0/24", "1.1.1.0/24"],
|
||||
"gre-client": ["gre-ivanov-01", "gre-ivanov-02", "gre-ivanov-03", "gre-petrov-01", "gre-kozlov-01"],
|
||||
"server": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
|
||||
"rtt": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
|
||||
"loss": ["mt-msk-core-01 → 8.8.8.8", "mt-spb-edge-01 → 8.8.8.8", "mt-fra-edge-01 → 8.8.8.8", "mt-ams-edge-01 → 1.1.1.1", "mt-sgp-edge-01 → 1.1.1.1"],
|
||||
"traffic": ["mt-msk-core-01", "mt-spb-edge-01", "mt-fra-edge-01", "mt-ams-edge-01", "mt-sgp-edge-01", "mt-ams-test-01", "mt-msk-lab-01"],
|
||||
}
|
||||
|
||||
const TYPE_CONDITIONS: Record<AlertType, string[]> = {
|
||||
"gre-tunnel": ["перешёл в offline", "восстановился"],
|
||||
"bgp-peer": ["разорвал сессию", "восстановил сессию"],
|
||||
"bgp-prefix": ["был отозван", "был получен"],
|
||||
"gre-client": ["отключился", "подключился"],
|
||||
"server": ["перешёл в offline", "перешёл в degraded", "восстановился"],
|
||||
"rtt": ["> порога", "< порога"],
|
||||
"loss": ["> порога"],
|
||||
"traffic": ["RX < порога", "TX < порога"],
|
||||
}
|
||||
|
||||
const THRESHOLD_UNIT: Partial<Record<AlertType, string>> = {
|
||||
rtt: "мс",
|
||||
loss: "%",
|
||||
traffic: "Мбит/с",
|
||||
}
|
||||
|
||||
const COOLDOWNS: AlertCooldown[] = ["1м", "5м", "15м", "1ч", "4ч", "24ч"]
|
||||
|
||||
// ─── mock data ────────────────────────────────────────────────────────────────
|
||||
|
||||
const INIT_RULES: AlertRule[] = [
|
||||
{ id: "r1", name: "GRE-туннель Иванов offline", type: "gre-tunnel", target: "gre-ivanov-01 / core-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "5м", lastFired: "3ч назад", chatId: "" },
|
||||
{ id: "r2", name: "BGP-сосед core-01 down", type: "bgp-peer", target: "mt-msk-core-01", condition: "разорвал сессию", severity: "critical", enabled: true, cooldown: "15м", lastFired: "1д назад", chatId: "" },
|
||||
{ id: "r3", name: "Высокая задержка FRA", type: "rtt", target: "mt-fra-edge-01 → 8.8.8.8", condition: "> 120 мс", severity: "warning", enabled: true, cooldown: "15м", lastFired: "45м назад", chatId: "" },
|
||||
{ id: "r4", name: "Потери пакетов AMS", type: "loss", target: "mt-ams-edge-01 → 1.1.1.1", condition: "> 5%", severity: "warning", enabled: true, cooldown: "5м", lastFired: null, chatId: "" },
|
||||
{ id: "r5", name: "Сервер SGP offline", type: "server", target: "mt-sgp-edge-01", condition: "перешёл в offline", severity: "critical", enabled: true, cooldown: "1ч", lastFired: "2д назад", chatId: "" },
|
||||
{ id: "r6", name: "BGP-префикс отозван core-01", type: "bgp-prefix", target: "любой префикс", condition: "был отозван", severity: "info", enabled: true, cooldown: "5м", lastFired: "6ч назад", chatId: "-1009876543210" },
|
||||
{ id: "r7", name: "GRE-клиент Козлов offline", type: "gre-client", target: "gre-kozlov-01", condition: "отключился", severity: "warning", enabled: false, cooldown: "1ч", lastFired: null, chatId: "" },
|
||||
{ id: "r8", name: "Низкий трафик AMS-edge", type: "traffic", target: "mt-ams-edge-01", condition: "RX < 10 Мбит/с", severity: "info", enabled: false, cooldown: "4ч", lastFired: null, chatId: "" },
|
||||
]
|
||||
|
||||
const INIT_HISTORY: HistoryEntry[] = [
|
||||
{ id: "h1", ruleName: "Высокая задержка FRA", severity: "warning", message: "RTT mt-fra-edge-01 → 8.8.8.8: 137 мс (порог 120 мс)", time: "45м назад", sent: true },
|
||||
{ id: "h2", ruleName: "GRE-туннель Иванов offline", severity: "critical", message: "gre-ivanov-01 / core-01 перешёл в offline", time: "3ч назад", sent: true },
|
||||
{ id: "h3", ruleName: "BGP-префикс отозван core-01", severity: "info", message: "Префикс 185.13.0.0/22 отозван на mt-msk-core-01", time: "6ч назад", sent: true },
|
||||
{ id: "h4", ruleName: "BGP-сосед core-01 down", severity: "critical", message: "BGP-сессия с mt-msk-core-01 разорвана", time: "1д назад", sent: true },
|
||||
{ id: "h5", ruleName: "Сервер SGP offline", severity: "critical", message: "mt-sgp-edge-01 недоступен, ping timeout", time: "2д назад", sent: false },
|
||||
]
|
||||
|
||||
const INIT_TG: TelegramConfig = {
|
||||
token: "7412358964:AAFkL9xZqBb2pC8nYrVtHmwKjXeOdSuN1A",
|
||||
chatId: "-1001234567890",
|
||||
connected: true,
|
||||
}
|
||||
|
||||
// ─── small helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button role="switch" aria-checked={checked} onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted-foreground/30",
|
||||
)}>
|
||||
<span className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0.5",
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-sm font-medium mb-1.5 leading-none">{children}</p>
|
||||
}
|
||||
|
||||
function FieldHint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-xs text-muted-foreground mt-1.5">{children}</p>
|
||||
}
|
||||
|
||||
function NativeSelect({ value, onChange, children, className }: {
|
||||
value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className={cn(
|
||||
// exact same tokens as the project's Input component
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1",
|
||||
"text-sm text-foreground transition-colors outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
|
||||
"dark:bg-input/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function SeverityDot({ severity }: { severity: AlertSeverity }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full shrink-0", SEVERITY_META[severity].dot)} />
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
|
||||
return (
|
||||
<span className={cn("text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap", SEVERITY_META[severity].badge)}>
|
||||
{severity === "critical" ? "Критич." : severity === "warning" ? "Предупр." : "Инфо"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── alert rule row ───────────────────────────────────────────────────────────
|
||||
|
||||
function AlertRuleRow({ rule, onToggle, onDelete }: {
|
||||
rule: AlertRule
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
const { Icon, iconClass, bg } = TYPE_META[rule.type]
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-3 px-4 py-3 hover:bg-muted/20 transition-colors group",
|
||||
!rule.enabled && "opacity-55",
|
||||
)}>
|
||||
{/* toggle */}
|
||||
<Toggle checked={rule.enabled} onChange={v => onToggle(rule.id, v)} />
|
||||
|
||||
{/* severity dot */}
|
||||
<SeverityDot severity={rule.severity} />
|
||||
|
||||
{/* type icon */}
|
||||
<div className={cn("size-7 rounded-md flex items-center justify-center shrink-0", bg)}>
|
||||
<Icon className={cn("size-3.5", iconClass)} />
|
||||
</div>
|
||||
|
||||
{/* name + target */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{rule.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{TYPE_META[rule.type].label} · <span className="font-mono">{rule.target}</span>
|
||||
{" · "}{rule.condition}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* cooldown */}
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
{rule.cooldown}
|
||||
</span>
|
||||
|
||||
{/* chat override badge */}
|
||||
{rule.chatId && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded border border-border text-muted-foreground hidden xl:inline shrink-0">
|
||||
#{rule.chatId.slice(-6)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* last fired */}
|
||||
<span className={cn(
|
||||
"text-[11px] shrink-0 w-[80px] text-right",
|
||||
rule.lastFired ? "text-muted-foreground" : "text-muted-foreground/40",
|
||||
)}>
|
||||
{rule.lastFired ?? "—"}
|
||||
</span>
|
||||
|
||||
{/* severity badge */}
|
||||
<div className="hidden lg:block shrink-0">
|
||||
<SeverityBadge severity={rule.severity} />
|
||||
</div>
|
||||
|
||||
{/* actions (visible on hover) */}
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||
<button
|
||||
onClick={() => onDelete(rule.id)}
|
||||
className="size-7 rounded flex items-center justify-center text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors">
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── telegram config card ─────────────────────────────────────────────────────
|
||||
|
||||
function TelegramCard({ cfg, onChange }: {
|
||||
cfg: TelegramConfig
|
||||
onChange: (c: TelegramConfig) => void
|
||||
}) {
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<"ok" | "fail" | null>(null)
|
||||
|
||||
const handleTest = () => {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
setTimeout(() => {
|
||||
setTesting(false)
|
||||
setTestResult(cfg.connected ? "ok" : "fail")
|
||||
setTimeout(() => setTestResult(null), 3000)
|
||||
}, 1400)
|
||||
}
|
||||
|
||||
const _maskedToken = cfg.token.replace(/:.+/, ":••••••••••••••••••••••••")
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3 pt-4 px-4">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-base">✈️</span> Telegram
|
||||
</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-[11px] font-normal px-2 py-0.5 rounded-full",
|
||||
cfg.connected
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", cfg.connected ? "bg-emerald-500" : "bg-muted-foreground")} />
|
||||
{cfg.connected ? "Подключён" : "Не настроен"}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 space-y-3">
|
||||
|
||||
{/* bot token */}
|
||||
<div>
|
||||
<FieldLabel>Bot Token</FieldLabel>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={cfg.token}
|
||||
onChange={e => onChange({ ...cfg, token: e.target.value })}
|
||||
className="text-xs font-mono h-8"
|
||||
placeholder="1234567890:AAF..."
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||||
{showToken ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(cfg.token)}
|
||||
className="size-8 rounded-md border border-input flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0">
|
||||
<CopyIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* default chat id */}
|
||||
<div>
|
||||
<FieldLabel>Chat ID по умолчанию</FieldLabel>
|
||||
<Input
|
||||
value={cfg.chatId}
|
||||
onChange={e => onChange({ ...cfg, chatId: e.target.value })}
|
||||
className="text-xs font-mono h-8"
|
||||
placeholder="-100xxxxxxxxxx"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Для групп используйте отрицательный ID. Каждое правило может переопределить.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* test button */}
|
||||
<Button
|
||||
size="sm" variant="outline" className="w-full h-8 text-xs gap-2"
|
||||
onClick={handleTest} disabled={testing}>
|
||||
{testing ? (
|
||||
<RefreshCwIcon className="size-3.5 animate-spin" />
|
||||
) : testResult === "ok" ? (
|
||||
<CheckIcon className="size-3.5 text-emerald-500" />
|
||||
) : testResult === "fail" ? (
|
||||
<XIcon className="size-3.5 text-destructive" />
|
||||
) : (
|
||||
<SendIcon className="size-3.5" />
|
||||
)}
|
||||
{testing ? "Отправка…"
|
||||
: testResult === "ok" ? "Сообщение отправлено"
|
||||
: testResult === "fail" ? "Ошибка отправки"
|
||||
: "Отправить тестовое сообщение"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── history card ─────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryCard({ entries }: { entries: HistoryEntry[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2 pt-4 px-4">
|
||||
<CardTitle className="text-sm">Журнал срабатываний</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">Нет срабатываний</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{entries.map(e => (
|
||||
<div key={e.id} className="flex gap-2.5 items-start">
|
||||
<SeverityDot severity={e.severity} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-medium leading-tight truncate">{e.ruleName}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-2">{e.message}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p className="text-[10px] text-muted-foreground whitespace-nowrap">{e.time}</p>
|
||||
<span className={cn(
|
||||
"text-[9px] font-medium px-1 py-0.5 rounded mt-0.5 inline-block",
|
||||
e.sent
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400",
|
||||
)}>
|
||||
{e.sent ? "✓ отправлено" : "✗ ошибка"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── add rule sheet ───────────────────────────────────────────────────────────
|
||||
|
||||
const BLANK_FORM = {
|
||||
name: "",
|
||||
type: "gre-tunnel" as AlertType,
|
||||
target: "",
|
||||
condition: "",
|
||||
threshold: "",
|
||||
severity: "warning" as AlertSeverity,
|
||||
cooldown: "5м" as AlertCooldown,
|
||||
chatId: "",
|
||||
}
|
||||
|
||||
function AddRuleSheet({ open, onClose, onSave }: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSave: (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({ ...BLANK_FORM })
|
||||
|
||||
// reset when open
|
||||
const handleOpen = (o: boolean) => { if (o) setForm({ ...BLANK_FORM }) }
|
||||
|
||||
const targets = TYPE_TARGETS[form.type]
|
||||
const conditions = TYPE_CONDITIONS[form.type]
|
||||
const unit = THRESHOLD_UNIT[form.type]
|
||||
|
||||
const conditionDisplay = unit && form.threshold
|
||||
? `${form.condition.replace("порога", form.threshold + " " + unit)}`
|
||||
: form.condition
|
||||
|
||||
const canSave = form.name.trim() && form.target && form.condition && (!unit || form.threshold)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return
|
||||
onSave({
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
target: form.target,
|
||||
condition: conditionDisplay || form.condition,
|
||||
severity: form.severity,
|
||||
cooldown: form.cooldown,
|
||||
chatId: form.chatId.trim(),
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={o => { if (!o) onClose(); handleOpen(o) }}>
|
||||
{/* p-0 + gap-0: we control all spacing internally */}
|
||||
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-md">
|
||||
|
||||
{/* ── fixed header ── */}
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<BellIcon className="size-4" />
|
||||
Новое правило оповещения
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{/* ── scrollable body ── */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
|
||||
{/* name */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Название правила</FieldLabel>
|
||||
<Input
|
||||
placeholder="Например: GRE-туннель Иванов offline"
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* type */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Тип события</FieldLabel>
|
||||
<NativeSelect
|
||||
value={form.type}
|
||||
onChange={v => setForm(f => ({ ...f, type: v as AlertType, target: "", condition: "", threshold: "" }))}
|
||||
>
|
||||
{(Object.entries(TYPE_META) as [AlertType, typeof TYPE_META[AlertType]][]).map(([t, m]) => (
|
||||
<option key={t} value={t}>{m.label}</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* target + condition on same row when both are simple selects */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Объект</FieldLabel>
|
||||
<NativeSelect value={form.target} onChange={v => setForm(f => ({ ...f, target: v }))}>
|
||||
<option value="" disabled>— выбрать —</option>
|
||||
{targets.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Условие</FieldLabel>
|
||||
<NativeSelect value={form.condition} onChange={v => setForm(f => ({ ...f, condition: v }))}>
|
||||
<option value="" disabled>— выбрать —</option>
|
||||
{conditions.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* threshold — only for RTT / loss / traffic */}
|
||||
{unit && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Пороговое значение</FieldLabel>
|
||||
<div className="flex items-center gap-0">
|
||||
<Input
|
||||
type="number" min="0"
|
||||
value={form.threshold}
|
||||
onChange={e => setForm(f => ({ ...f, threshold: e.target.value }))}
|
||||
className="rounded-r-none"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="h-8 px-3 flex items-center rounded-r-lg border border-l-0 border-input
|
||||
bg-muted text-sm text-muted-foreground shrink-0">
|
||||
{unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* severity — vertical radio cards */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Серьёзность</FieldLabel>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{(["critical", "warning", "info"] as AlertSeverity[]).map(s => {
|
||||
const active = form.severity === s
|
||||
return (
|
||||
<button key={s} onClick={() => setForm(f => ({ ...f, severity: s }))}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors w-full",
|
||||
active ? SEVERITY_META[s].chip : "border-input hover:bg-muted/50 text-foreground",
|
||||
)}>
|
||||
<span className={cn("size-2.5 rounded-full shrink-0", SEVERITY_META[s].dot)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-none">{SEVERITY_META[s].label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-none">
|
||||
{s === "critical" ? "Немедленное уведомление, звуковой сигнал"
|
||||
: s === "warning" ? "Важное событие, тихое уведомление"
|
||||
: "Информационное, без уведомления"}
|
||||
</p>
|
||||
</div>
|
||||
{active && <CheckIcon className="size-4 ml-auto shrink-0 opacity-70" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* cooldown */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Повторять не чаще чем</FieldLabel>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{COOLDOWNS.map(c => (
|
||||
<button key={c} onClick={() => setForm(f => ({ ...f, cooldown: c }))}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-lg border text-sm font-mono transition-colors",
|
||||
form.cooldown === c
|
||||
? "border-primary bg-primary/10 text-primary font-semibold"
|
||||
: "border-input text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* chat id override */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FieldLabel>Chat ID — переопределить</FieldLabel>
|
||||
<Input
|
||||
value={form.chatId}
|
||||
onChange={e => setForm(f => ({ ...f, chatId: e.target.value }))}
|
||||
className="font-mono text-sm"
|
||||
placeholder="-100xxxxxxxxxx"
|
||||
/>
|
||||
<FieldHint>Пусто → используется глобальный Chat ID из настроек Telegram</FieldHint>
|
||||
</div>
|
||||
|
||||
{/* Telegram message preview */}
|
||||
{form.name && form.target && form.condition && (
|
||||
<div className="rounded-xl border border-border bg-muted/40 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Предпросмотр сообщения
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 font-mono text-xs leading-relaxed space-y-0.5">
|
||||
<p>
|
||||
{form.severity === "critical" ? "🔴" : form.severity === "warning" ? "🟡" : "🔵"}
|
||||
{" "}<span className="font-semibold">{form.name}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground">Объект: {form.target}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Событие: {conditionDisplay || form.condition}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Cooldown: {form.cooldown}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Chat: {form.chatId || "(глобальный)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── fixed footer ── */}
|
||||
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={onClose}>Отмена</Button>
|
||||
<Button className="flex-1" onClick={handleSave} disabled={!canSave}>
|
||||
Сохранить правило
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SeverityFilter = AlertSeverity | "all"
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [rules, setRules] = useState<AlertRule[]>(INIT_RULES)
|
||||
const [history] = useState<HistoryEntry[]>(INIT_HISTORY)
|
||||
const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all")
|
||||
const [onlyActive, setOnlyActive] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const total = rules.length
|
||||
const active = rules.filter(r => r.enabled).length
|
||||
const critical = rules.filter(r => r.severity === "critical").length
|
||||
const warning = rules.filter(r => r.severity === "warning").length
|
||||
const info = rules.filter(r => r.severity === "info").length
|
||||
|
||||
const filteredRules = useMemo(() => rules.filter(r => {
|
||||
if (sevFilter !== "all" && r.severity !== sevFilter) return false
|
||||
if (onlyActive && !r.enabled) return false
|
||||
if (search && !r.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!r.target.toLowerCase().includes(search.toLowerCase())) return false
|
||||
return true
|
||||
}), [rules, sevFilter, onlyActive, search])
|
||||
|
||||
const handleToggle = (id: string, enabled: boolean) =>
|
||||
setRules(rs => rs.map(r => r.id === id ? { ...r, enabled } : r))
|
||||
|
||||
const handleDelete = (id: string) =>
|
||||
setRules(rs => rs.filter(r => r.id !== id))
|
||||
|
||||
const handleAdd = (rule: Omit<AlertRule, "id" | "lastFired" | "enabled">) => {
|
||||
setRules(rs => [...rs, {
|
||||
...rule,
|
||||
id: `r${Date.now()}`,
|
||||
enabled: true,
|
||||
lastFired: null,
|
||||
}])
|
||||
}
|
||||
|
||||
// Summary chip click handler
|
||||
const handleChip = (sev: SeverityFilter) =>
|
||||
setSevFilter(f => f === sev ? "all" : sev)
|
||||
|
||||
const chipActive = (sev: SeverityFilter) => sevFilter === sev
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Система" }, { label: "Оповещения" }]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCwIcon className="size-4" />Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<PlusIcon className="size-4" />Добавить правило
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── summary chip bar ── */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
{/* stat chips */}
|
||||
{([
|
||||
{ key: "all", label: `Всего: ${total}`, cls: "border-border bg-muted/50 text-foreground" },
|
||||
{ key: "critical", label: `Критических: ${critical}`, cls: SEVERITY_META.critical.chip },
|
||||
{ key: "warning", label: `Предупреждений: ${warning}`, cls: SEVERITY_META.warning.chip },
|
||||
{ key: "info", label: `Информационных: ${info}`, cls: SEVERITY_META.info.chip },
|
||||
] as { key: SeverityFilter; label: string; cls: string }[]).map(({ key, label, cls }) => (
|
||||
<button key={key} onClick={() => handleChip(key)}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||||
chipActive(key)
|
||||
? cls
|
||||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||||
)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* active count chip */}
|
||||
<button onClick={() => setOnlyActive(v => !v)}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1.5 rounded-full border font-medium transition-colors",
|
||||
onlyActive
|
||||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-border bg-muted/40 text-muted-foreground hover:bg-muted",
|
||||
)}>
|
||||
Активных: {active}
|
||||
</button>
|
||||
|
||||
{/* telegram status */}
|
||||
<span className={cn(
|
||||
"ml-auto text-xs px-3 py-1.5 rounded-full border flex items-center gap-1.5 font-medium",
|
||||
tg.connected
|
||||
? "border-emerald-400 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-red-400 bg-red-500/10 text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
<span className={cn("size-1.5 rounded-full", tg.connected ? "bg-emerald-500" : "bg-red-500")} />
|
||||
{tg.connected ? "Telegram: Подключён" : "Telegram: Не настроен"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── main layout ── */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1fr_300px] gap-5 items-start">
|
||||
|
||||
{/* ── rules card ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-0 pt-4 px-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-sm">Правила оповещения</CardTitle>
|
||||
<Input
|
||||
placeholder="Поиск…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="h-7 text-xs max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0 pb-0 pt-2">
|
||||
{filteredRules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground/40">
|
||||
<BellOffIcon className="size-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">Правила не найдены</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{filteredRules.map(r => (
|
||||
<AlertRuleRow
|
||||
key={r.id}
|
||||
rule={r}
|
||||
onToggle={handleToggle}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* footer */}
|
||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{filteredRules.length} из {total} правил
|
||||
{onlyActive && " · только активные"}
|
||||
{sevFilter !== "all" && ` · ${SEVERITY_META[sevFilter].label.toLowerCase()}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
<PlusIcon className="size-3" />Добавить правило
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── right sidebar ── */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<TelegramCard cfg={tg} onChange={setTg} />
|
||||
<HistoryCard entries={history} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddRuleSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSave={handleAdd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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="1–28">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
Раздел работает в режиме "Живые данные". Переключи источник данных в настройках.
|
||||
</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">
|
||||
Нет маршрутов в БД для этого сервера. Нажми "Router => DB" для загрузки.
|
||||
</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
@@ -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
@@ -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) — уникальный идентификатор сегмента (0–16777215).
|
||||
Рекомендуется использовать совместно с 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
Reference in New Issue
Block a user